From 4dd0160bf4d56c09392148a21548a5188de1242c Mon Sep 17 00:00:00 2001 From: muhammadburhandevv-hub Date: Fri, 28 Aug 2026 15:27:05 +0500 Subject: [PATCH] fix(ebuild): prevent false success for unsupported system backend Signed-off-by: muhammadburhandevv-hub --- ebuild/build/dispatch.py | 58 ++++++++++++-------------- ebuild/core/config.py | 19 +++++---- tests/ebuild/test_build_cli.py | 34 +++++++++++++++ tests/ebuild/test_config_validation.py | 53 +++++++++++++++++++++++ tests/ebuild/test_dispatch.py | 9 ++-- tests/unit/test_backend_dispatch.py | 28 +++++++++++++ 6 files changed, 159 insertions(+), 42 deletions(-) create mode 100644 tests/ebuild/test_build_cli.py create mode 100644 tests/unit/test_backend_dispatch.py diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708df..9bfe847 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -13,7 +13,7 @@ import subprocess import sys from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Set logger = logging.getLogger(__name__) @@ -24,6 +24,25 @@ TIER_3 = {"cargo"} ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +SUPPORTED_BACKENDS = TIER_1 | TIER_2 | TIER_3 + + +class BackendError(RuntimeError): + """Raised when the external dispatcher cannot handle a backend.""" + + +def _validate_backend(backend: str, supported: Set[str]) -> None: + """Reject values that the requested dispatcher operation cannot handle.""" + if backend in supported: + return + + if backend in ALL_BACKENDS: + message = f"BackendDispatcher cannot handle backend '{backend}'." + else: + message = f"Unknown build backend '{backend}'." + + supported_names = ", ".join(sorted(supported)) + raise BackendError(f"{message} Supported backends: {supported_names}.") def detect_backend(source_dir: Path) -> str: @@ -100,8 +119,9 @@ def configure( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + BackendError: If the backend cannot be configured here. """ + _validate_backend(backend, SUPPORTED_BACKENDS) config = config or {} self.build_dir.mkdir(parents=True, exist_ok=True) @@ -121,24 +141,9 @@ def configure( elif backend == "cargo": pass # Cargo does not have a separate configure step - elif backend in ("make", "kbuild", "ninja"): + elif backend in ("make", "kbuild"): pass # No separate configure step - else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) - - else: - raise RuntimeError( - f"BackendDispatcher cannot configure backend '{backend}'. " - "This dispatcher only handles cmake, meson, and cargo " - "(make/kbuild need no configure step). ebuild's own ninja " - "backend is invoked directly and requires 'targets' in " - "build.yaml -- add targets or choose another backend." - ) - def build( self, backend: str, @@ -154,8 +159,9 @@ def build( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + BackendError: If the backend cannot be built here. """ + _validate_backend(backend, SUPPORTED_BACKENDS) config = config or {} if backend == "cmake": @@ -184,12 +190,6 @@ def build( cmd = ["make", "-C", str(self.source_dir)] _run_or_log(cmd, dry_run) - else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) - def clean( self, backend: str, @@ -203,8 +203,9 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + BackendError: If the backend cannot be cleaned here. """ + _validate_backend(backend, ALL_BACKENDS) if backend == "cmake": _run_or_log( ["cmake", "--build", str(self.build_dir), "--target", "clean"], @@ -236,8 +237,3 @@ def clean( dry_run, check=False, ) - else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 3695545..c8b5026 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -94,6 +94,7 @@ class ProjectConfig: source_dir: Path = field(default_factory=lambda: Path(".")) backend: str = "auto" backend_config: Dict[str, Any] = field(default_factory=dict) + system_config: Dict[str, Any] = field(default_factory=dict) def get_target(self, name: str) -> Optional[TargetConfig]: for t in self.targets: @@ -221,13 +222,16 @@ def load_config(config_path: str | Path) -> ProjectConfig: if not isinstance(backend_config, dict): raise ConfigError("'backend_config' must be a mapping.") + backend_config = dict(backend_config) - # For system builds, pull from 'system' section - if raw.get("system") and isinstance(raw["system"], dict): - backend_config.update(raw["system"]) - - if backend == "auto": - backend = "system" + # System-image settings are not a compilation backend. Keep them separate + # so normal backend selection can still auto-detect CMake, Ninja, etc. + system_config = raw.get("system", {}) + if system_config is None: + system_config = {} + if not isinstance(system_config, dict): + raise ConfigError("'system' must be a mapping.") + system_config = dict(system_config) # For cmake/make/meson builds, pull defines from config if raw.get("cmake") and isinstance(raw["cmake"], dict): @@ -303,4 +307,5 @@ def load_config(config_path: str | Path) -> ProjectConfig: source_dir=config_path.parent, backend=backend, backend_config=backend_config, - ) \ No newline at end of file + system_config=system_config, + ) diff --git a/tests/ebuild/test_build_cli.py b/tests/ebuild/test_build_cli.py new file mode 100644 index 0000000..0d46cea --- /dev/null +++ b/tests/ebuild/test_build_cli.py @@ -0,0 +1,34 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""CLI regressions for build backend selection.""" + +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import cli + + +def test_system_only_config_does_not_report_build_success(tmp_path): + config_path = tmp_path / "build.yaml" + config_path.write_text( + yaml.safe_dump( + { + "project": {"name": "system-image"}, + "system": {"hostname": "eos-device", "image_format": "tar"}, + } + ), + encoding="utf-8", + ) + build_dir = tmp_path / "build" + + result = CliRunner().invoke( + cli, + ["build", "--config", str(config_path), "--build-dir", str(build_dir)], + ) + + assert result.exit_code == 1 + assert "Auto-detected backend: ninja" in result.output + assert "BackendDispatcher cannot handle backend 'ninja'" in result.output + assert "Build completed successfully" not in result.output + assert not build_dir.exists() diff --git a/tests/ebuild/test_config_validation.py b/tests/ebuild/test_config_validation.py index a8b1d7f..f733404 100644 --- a/tests/ebuild/test_config_validation.py +++ b/tests/ebuild/test_config_validation.py @@ -96,3 +96,56 @@ def test_toolchain_mapping_is_parsed(tmp_path): assert config.toolchain.sysroot == "/opt/arm-none-eabi" assert config.toolchain.extra_cflags == ["-mcpu=cortex-m4"] assert config.toolchain.extra_ldflags == ["--specs=nosys.specs"] + + +def test_system_section_does_not_select_system_backend(tmp_path): + path = write_config( + tmp_path, + { + "project": {"name": "system-app"}, + "targets": [ + {"name": "app", "type": "executable", "sources": ["main.c"]} + ], + "system": { + "hostname": "eos-device", + "image_format": "ext4", + }, + }, + ) + + config = load_config(path) + + assert config.backend == "auto" + assert config.backend_config == {} + assert config.system_config == { + "hostname": "eos-device", + "image_format": "ext4", + } + + +def test_system_section_does_not_override_explicit_backend(tmp_path): + path = write_config( + tmp_path, + { + "project": {"name": "cmake-system-app"}, + "backend": "cmake", + "cmake": {"defines": {"BUILD_TESTS": "ON"}}, + "system": {"hostname": "eos-device"}, + }, + ) + + config = load_config(path) + + assert config.backend == "cmake" + assert config.backend_config == {"defines": {"BUILD_TESTS": "ON"}} + assert config.system_config == {"hostname": "eos-device"} + + +def test_system_config_must_be_mapping(tmp_path): + path = write_config( + tmp_path, + {"project": {"name": "demo"}, "system": ["invalid"]}, + ) + + with pytest.raises(ConfigError, match="'system' must be a mapping"): + load_config(path) diff --git a/tests/ebuild/test_dispatch.py b/tests/ebuild/test_dispatch.py index aa71c39..35a57db 100644 --- a/tests/ebuild/test_dispatch.py +++ b/tests/ebuild/test_dispatch.py @@ -12,6 +12,7 @@ from ebuild.build.dispatch import ( ALL_BACKENDS, BackendDispatcher, + BackendError, detect_backend, ) @@ -65,21 +66,21 @@ def test_cmake_takes_priority_over_makefile(self, tmp_path): class TestUnknownBackend: - """Unknown backends must raise ValueError rather than silently skip.""" + """Unknown backends must raise BackendError rather than silently skip.""" def test_configure_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend 'bazel'"): + with pytest.raises(BackendError, match="Unknown build backend 'bazel'"): d.configure("bazel") def test_build_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend"): + with pytest.raises(BackendError, match="Unknown build backend"): d.build("gradle") def test_clean_unknown_raises(self, tmp_path): d = BackendDispatcher(tmp_path, tmp_path / "build") - with pytest.raises(ValueError, match="Unknown build backend"): + with pytest.raises(BackendError, match="Unknown build backend"): d.clean("scons") diff --git a/tests/unit/test_backend_dispatch.py b/tests/unit/test_backend_dispatch.py new file mode 100644 index 0000000..bf78aee --- /dev/null +++ b/tests/unit/test_backend_dispatch.py @@ -0,0 +1,28 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Regression tests for external build backend dispatch.""" + +import pytest + +from ebuild.build.dispatch import BackendDispatcher, BackendError + + +@pytest.mark.parametrize("operation", ["configure", "build", "clean"]) +def test_unsupported_backend_fails_closed(tmp_path, operation): + dispatcher = BackendDispatcher(tmp_path, tmp_path / "build") + + with pytest.raises( + BackendError, + match="Unknown build backend 'system'", + ): + getattr(dispatcher, operation)("system") + + +def test_tier_one_configure_is_a_supported_noop(tmp_path): + build_dir = tmp_path / "build" + dispatcher = BackendDispatcher(tmp_path, build_dir) + + dispatcher.configure("make") + + assert build_dir.is_dir()