diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ba0175..a4f48a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,9 @@ `cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1). ### Fixed +- Shared-library Ninja link commands now preserve toolchain `extra_ldflags` + and `sysroot`, before target linker flags and package library paths, matching + executable targets. Previously those toolchain settings were silently ignored. - **`ebuild test` now finds Windows test binaries.** The Ninja edge for a native `type: test` target already carried the platform suffix (`_exe_suffix()` names it `.exe` on Windows), but `ebuild test` diff --git a/TASKS.md b/TASKS.md index 09f6bcb..a681074 100644 --- a/TASKS.md +++ b/TASKS.md @@ -11,6 +11,7 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. | ID | Task | Owner | Mode | Status | Depends on | |----|------|-------|------|--------|------------| +| T-006 | Preserve toolchain linker settings for shared libraries ([#125](https://github.com/embeddedos-org/ebuild/pull/125)) | backend | Verification | review | none | | T-002 | Fix Windows Ninja test-target path parsing | backend | Maintenance | review | none | | T-003 | `ebuild package` looks for the unsuffixed binary on Windows (`_build/app` rather than `_build/app.exe`) | backend | Maintenance | review | none | | T-004 | `_report_footprint` (the flash/RAM report `ebuild build` prints) looks for the unsuffixed binary on Windows, and fails silently rather than logging why | backend | Maintenance | review | none | diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index de417bd..72c5482 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -280,7 +280,7 @@ def _write_ninja(self) -> None: # get, which the rule preamble alone does not supply. The # "build a shared object" flag itself lives in the # link_shared rule, so it must not be repeated here. - ldflags = list(target.ldflags) + ldflags = toolchain_ldflags + list(target.ldflags) libs = [] for pkg_name in target.uses: pkg = self.package_paths.get(pkg_name) diff --git a/tests/unit/test_shared_library_toolchain.py b/tests/unit/test_shared_library_toolchain.py new file mode 100644 index 0000000..69da3fc --- /dev/null +++ b/tests/unit/test_shared_library_toolchain.py @@ -0,0 +1,88 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Regression tests for toolchain settings in shared-library link commands.""" + +import shutil +import sys +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from ebuild.build.ninja_backend import NinjaBackend, PackagePaths +from ebuild.build.toolchain import ResolvedToolchain +from ebuild.cli.commands import cli +from ebuild.core.config import ProjectConfig, TargetConfig + + +@pytest.mark.parametrize("sysroot", [None, "/opt/target-sysroot"]) +def test_shared_link_preserves_toolchain_flags_without_mutation(tmp_path, sysroot): + toolchain = ResolvedToolchain(ldflags=["-Wl,--no-undefined"], sysroot=sysroot) + targets = [ + TargetConfig( + name=name, + target_type="shared_library", + sources=[f"{name}.c"], + ldflags=[f"-Wl,-soname,lib{name}.so"], + uses=[name], + ) + for name in ("first", "second") + ] + packages = { + name: PackagePaths(lib_dirs=[Path(f"vendor/{name}")], libraries=[name]) + for name in ("first", "second") + } + config = ProjectConfig(name="libs", version="1.0", targets=targets) + NinjaBackend(config, tmp_path, toolchain, packages).generate() + manifest = (tmp_path / "build.ninja").read_text(encoding="utf-8") + + for target in targets: + edge = next( + block for block in manifest.split("\n\n") + if ": link_shared " in block and f"lib{target.name}." in block + ) + expected = ["-Wl,--no-undefined"] + if sysroot: + expected.append(f"--sysroot={sysroot}") + expected += target.ldflags + [f"-L{Path('vendor') / target.name}"] + assert edge.splitlines()[1:] == [ + f" ldflags = {' '.join(expected)}", + f" libs = -l{target.name}", + ] + assert target.ldflags == [f"-Wl,-soname,lib{target.name}.so"] + assert toolchain.ldflags == ["-Wl,--no-undefined"] + + +@pytest.mark.skipif( + not sys.platform.startswith("linux") or shutil.which("gcc") is None, + reason="requires Linux and GCC for --no-undefined shared-library linking", +) +def test_cli_shared_library_honors_toolchain_no_undefined(tmp_path, monkeypatch): + pytest.importorskip("ninja", reason="ninja Python package not installed") + monkeypatch.chdir(tmp_path) + (tmp_path / "build.yaml").write_text( + "project:\n name: shared-link-check\n version: '1.0'\n" + "toolchain:\n compiler: gcc\n" + " extra_ldflags: ['-Wl,--no-undefined']\n" + "targets:\n - name: example\n type: shared_library\n" + " sources: [example.c]\n cflags: [-fPIC]\n", + encoding="utf-8", + ) + source = tmp_path / "example.c" + source.write_text("int example(void) { return 42; }\n", encoding="utf-8") + runner = CliRunner() + good = runner.invoke(cli, ["build", "--backend", "ninja", "--build-dir", "good"]) + assert good.exit_code == 0, good.output + assert (tmp_path / "good" / "libexample.so").is_file() + + source.write_text( + "extern int missing_symbol(void);\n" + "int example(void) { return missing_symbol(); }\n", + encoding="utf-8", + ) + # A separate output directory avoids timestamp-based incremental decisions. + bad = runner.invoke(cli, ["build", "--backend", "ninja", "--build-dir", "bad"]) + assert bad.exit_code != 0, bad.output + assert "undefined reference" in bad.output, bad.output + assert "missing_symbol" in bad.output, bad.output