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 c610d1c..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: @@ -70,6 +70,7 @@ from cldk import CLDK analysis = CLDK.java(project_path="/path/to/java/project") # analysis = CLDK.python(project_path="/path/to/python/project") # analysis = CLDK.typescript(project_path="/path/to/ts/project") +# analysis = CLDK.go(project_path="/path/to/go/project") ``` Walk the symbol table and pull method bodies: @@ -116,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 @@ -127,6 +128,7 @@ Each language is analyzed by a dedicated `codeanalyzer-*` engine; CLDK normalize | **Java** | [`codeanalyzer-java`](https://github.com/codellm-devkit/codeanalyzer-java) | WALA + JavaParser. Bytecode-level call graphs, type hierarchies, symbol resolution, CRUD-operation and entry-point detection. Optional read-only **Neo4j** graph backend. | | **Python** | [`codeanalyzer-python`](https://github.com/codellm-devkit/codeanalyzer-python) | Jedi with PyCG-based call graphs. Symbol tables, call graphs, and class/method resolution. Optional read-only **Neo4j** graph backend. | | **TypeScript / JavaScript** | [`codeanalyzer-typescript`](https://github.com/codellm-devkit/codeanalyzer-typescript) | ts-morph with Jelly-based call graphs. Symbols, call graph, types, decorators, and call sites. Optional read-only **Neo4j** graph backend. | +| **Go** | [`codeanalyzer-go`](https://github.com/codellm-devkit/codeanalyzer-go) | `go/packages` + `go/types`. Symbol table, resolver-based call graph, struct/interface types, pointer/value receivers, multi-return, goroutine call sites, embedded fields. External binary on `PATH`. | The backend is selected by the **type** of the `backend=` config you pass to a factory: the in-process analyzer (default) or a `Neo4jConnectionConfig` for the read-only graph backend. @@ -149,13 +151,15 @@ graph TD J --> EJ[codeanalyzer-java
WALA · JavaParser] P --> EP[codeanalyzer-python
Jedi · PyCG] T --> ET[codeanalyzer-typescript
ts-morph · Jelly] + A --> G[cldk.analysis.go] + G --> EG[codeanalyzer-go
go/packages · go/types] J -. read-only .-> N[(Neo4j)] P -. read-only .-> N 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. diff --git a/cldk/analysis/commons/backend_config.py b/cldk/analysis/commons/backend_config.py index 152986b..d56fb75 100644 --- a/cldk/analysis/commons/backend_config.py +++ b/cldk/analysis/commons/backend_config.py @@ -41,7 +41,7 @@ # The canonical sub-directory name each language's artifacts live under inside the shared cache # root. Keyed so that a polyglot repository analyzed under more than one language does not have its # backends overwrite a single shared ``analysis.json``. -_CACHE_KEYS = {"java": "java", "python": "python", "typescript": "typescript", "c": "c"} +_CACHE_KEYS = {"java": "java", "python": "python", "typescript": "typescript", "c": "c", "go": "go"} @dataclass @@ -113,10 +113,20 @@ class Neo4jConnectionConfig: application_name: str | None = None +@dataclass +class GoCodeAnalyzerConfig(CodeAnalyzerConfig): + """Select the in-process codeanalyzer backend for Go. + + Inherits :attr:`cache_dir` from :class:`CodeAnalyzerConfig`. Go analysis has no + additional backend-specific knobs at this time. + """ + + # Per-language discriminated unions the facades match on. JavaBackend = Union[CodeAnalyzerConfig, Neo4jConnectionConfig] PyBackend = Union[PyCodeAnalyzerConfig, Neo4jConnectionConfig] TSBackend = Union[TSCodeAnalyzerConfig, CodeAnalyzerConfig, Neo4jConnectionConfig] +GoBackend = Union[GoCodeAnalyzerConfig, CodeAnalyzerConfig] def cache_subdir(cache_dir: Union[str, Path, None], project_dir: Union[str, Path, None], language: str) -> Path | None: diff --git a/cldk/analysis/go/__init__.py b/cldk/analysis/go/__init__.py new file mode 100644 index 0000000..e6ad7a2 --- /dev/null +++ b/cldk/analysis/go/__init__.py @@ -0,0 +1,22 @@ +################################################################################ +# 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.backend import GoAnalysisBackend +from cldk.analysis.go.go_analysis import 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/__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..5928505 --- /dev/null +++ b/cldk/analysis/go/codeanalyzer/codeanalyzer.py @@ -0,0 +1,231 @@ +################################################################################ +# 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 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 +from subprocess import CompletedProcess +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 + +logger = logging.getLogger(__name__) + +_BINARY_NAME = "codeanalyzer-go" + + +class GoCodeanalyzer(GoAnalysisBackend): + """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_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 ────────────────────────────────────────────────────── + + def _get_codeanalyzer_exec(self) -> List[str]: + """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 ───────────────────────────────────────────────────── + + @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.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: + 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..bd009ee --- /dev/null +++ b/cldk/analysis/go/go_analysis.py @@ -0,0 +1,227 @@ +################################################################################ +# 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 + +import networkx as nx + +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, + 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_level: ``"symbol_table"`` (default) or ``"call_graph"``. + eager_analysis: When ``True``, always re-run the binary. + backend: Backend configuration. Defaults to :class:`~cldk.analysis.commons.backend_config.GoCodeAnalyzerConfig`. + """ + + def __init__( + self, + project_dir: Path | None, + 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: GoAnalysisBackend = GoCodeanalyzer( + project_dir=self.project_dir, + 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 + + # ── Application / symbol table ───────────────────────────────────────────── + + def get_application_view(self) -> GoApplication: + """Return the complete analyzed application model.""" + return self._codeanalyzer.get_application() + + def get_symbol_table(self) -> Dict[str, GoFile]: + """Return the symbol table mapping relative file paths to :class:`GoFile` objects.""" + return self._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.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._codeanalyzer.get_application().call_graph diff --git a/cldk/core.py b/cldk/core.py index faa8902..b740f89 100644 --- a/cldk/core.py +++ b/cldk/core.py @@ -47,9 +47,12 @@ 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.backend_config import ( CodeAnalyzerConfig, + GoBackend, + GoCodeAnalyzerConfig, JavaBackend, Neo4jConnectionConfig, PyBackend, @@ -242,6 +245,32 @@ def c(project_path: str | Path) -> CAnalysis: """Create a C analysis facade for the given project directory.""" return CAnalysis(project_dir=_normalize_project_path(project_path)) + @staticmethod + 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: + """Create a Go analysis facade. + + 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`. + """ + return GoAnalysis( + project_dir=_normalize_project_path(project_path), + analysis_level=analysis_level, + eager_analysis=eager, + backend=backend, + target_files=target_files, + ) + def analysis( self, project_path: str | Path | None = None, @@ -254,7 +283,7 @@ def analysis( cache_dir: str | Path | None = None, use_ray: bool = False, neo4j_config: "Neo4jConnectionConfig | None" = None, - ) -> JavaAnalysis | PythonAnalysis | CAnalysis | TypeScriptAnalysis: + ) -> JavaAnalysis | PythonAnalysis | CAnalysis | TypeScriptAnalysis | GoAnalysis: """Deprecated entry point. Use the per-language factory methods instead. ``CLDK(language).analysis(...)`` is retained as a thin compatibility shim that forwards to @@ -320,6 +349,16 @@ def analysis( ) elif self.language == "c": return CLDK.c(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 CLDK.go( + project_path=project_path, + analysis_level=analysis_level, + target_files=target_files, + eager=eager, + backend=GoCodeAnalyzerConfig(cache_dir=cache_root), + ) 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 999b368..d783d8c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -90,6 +90,7 @@ include = [ codeanalyzer-java = "2.4.1" codeanalyzer-python = "0.3.0" codeanalyzer-typescript = "0.4.3" +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..85e79a7 --- /dev/null +++ b/tests/analysis/go/test_go_analysis.py @@ -0,0 +1,389 @@ +################################################################################ +# 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, GoAnalysisBackend +from cldk.analysis.go.codeanalyzer import GoCodeanalyzer +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_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_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_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_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_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_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_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_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_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_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_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" + + +# ── 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 + + +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 + + +# ── 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 new file mode 100644 index 0000000..92b2419 --- /dev/null +++ b/tests/analysis/go/test_go_e2e.py @@ -0,0 +1,412 @@ +################################################################################ +# 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 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, + 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 os +import shutil +from pathlib import Path + +import pytest + +from cldk.analysis.go import GoAnalysis +from cldk.analysis import AnalysisLevel +from cldk.analysis.commons.backend_config import GoCodeAnalyzerConfig +from cldk.models.go.models import GoApplication + + +# ── Helpers ──────────────────────────────────────────────────────────────────── + +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( + not _codeanalyzer_available(), + reason="codeanalyzer-go not resolvable — pip install codeanalyzer-go, put codeanalyzer-go/cango on PATH, or set $CODEANALYZER_GO_BIN", +) + + +def _analysis(tmp_path: Path, level: str = AnalysisLevel.symbol_table) -> GoAnalysis: + return GoAnalysis( + project_dir=FIXTURE_DIR, + analysis_level=level, + eager_analysis=True, + backend=GoCodeAnalyzerConfig(cache_dir=tmp_path), + ) + + +# ── 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 / "go" / "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_level=AnalysisLevel.symbol_table, + eager_analysis=True, + backend=GoCodeAnalyzerConfig(cache_dir=tmp_path), + ) + mtime_after_first = (tmp_path / "go" / "analysis.json").stat().st_mtime + + time.sleep(0.05) + + # Second run (eager=False) — must reuse the cached file. + GoAnalysis( + project_dir=FIXTURE_DIR, + analysis_level=AnalysisLevel.symbol_table, + eager_analysis=False, + backend=GoCodeAnalyzerConfig(cache_dir=tmp_path), + ) + mtime_after_second = (tmp_path / "go" / "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 1681ba5..5642979 100644 --- a/uv.lock +++ b/uv.lock @@ -300,7 +300,7 @@ wheels = [ [[package]] name = "cldk" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "clang" },