From c2c91d83c219a111327a314bae3e158c60e571a7 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Fri, 28 Aug 2026 02:36:19 -0700 Subject: [PATCH 1/3] =?UTF-8?q?fix(mvp):=20repair=20the=20first-run=20path?= =?UTF-8?q?=20=E2=80=94=20setup,=20build,=20and=20the=20unit=20suite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Measured against the MVP developer walk. On origin/master a new developer cannot reach a built binary, and `pytest tests/unit` cannot even be collected. Five defects, each verified by running the tool. 1. `ebuild setup` failed for everyone, on the first command. DEFAULT_CONFIG pinned branch "main" for both cached repos while eos and eBoot both default to "master": fatal: Remote branch main not found in upstream origin 2. `ebuild build` could not run at all. ebuild/build/dispatch.py carried two consecutive `else:` blocks — a SyntaxError. Python does not parse a module until something imports it, so this sat on master reachable only by the command that touches it. It also made tests/unit uncollectable. The two branches were duplicates of each other. The RuntimeError one is kept: tests/unit/test_dispatch.py documents the intent, that an unhandled backend must fail loudly rather than silently do nothing and let the caller report a false "Build completed successfully". "ninja" is therefore no longer swallowed by the make/kbuild no-op branch, and build() raises for the same reason. 3. NinjaBackend._object_path was called twice and defined nowhere: AttributeError: 'NinjaBackend' object has no attribute '_object_path' Implemented. Objects are namespaced per target, since ninja rejects two edges writing one output and two targets may use different cflags. The source path is flattened into the filename rather than mirrored, so a source from outside the project cannot place its object outside the build directory. 4. ninja was invoked as `sys.executable -m ninja`, so a machine with ninja properly installed still failed with "No module named ninja". Now prefers the binary on PATH and falls back to the wheel. 5. tests/unit/test_ninja_backend.py asserted the string "-shared" was absent from a static-library build file, but the link_shared *rule* is always declared in the preamble. The assertion now checks that no build *edge* uses link_shared, which is what it meant. This test had never run — defect 2 made the module uncollectable. Added tests/unit/test_sources_are_importable.py so this class of bug cannot merge again: every shipped .py is parsed with ast, the CLI and its lazily imported modules are imported, `--help` is run for each MVP command, and the cached-repo default branch is checked against the actual remote with git ls-remote. Verified: - tests/unit: 111 passed. On origin/master the same command exits with "SyntaxError ... Interrupted: 1 error during collection". - From a clean ~/.ebuild: `ebuild setup` and `ebuild new hello` both succeed, and `ebuild build` now generates build.ninja and compile_commands.json and drives gcc. Still failing, deliberately not fixed here: the scaffold emits #include with no include path to the eos repo that setup just cloned, so all six templates stop at "fatal error: eos/hal.h". That needs a decision about how an app declares a dependency on eos, not a guess. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/build/dispatch.py | 38 ++++-- ebuild/build/ninja_backend.py | 16 +++ ebuild/cli/commands.py | 3 +- ebuild/deps/__init__.py | 4 +- ebuild/deps/manager.py | 6 +- tests/unit/test_ninja_backend.py | 11 +- tests/unit/test_sources_are_importable.py | 140 ++++++++++++++++++++++ 7 files changed, 197 insertions(+), 21 deletions(-) create mode 100644 tests/unit/test_sources_are_importable.py diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708dfa..b69709bd 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -26,6 +26,23 @@ ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +def ninja_command(): + """Return the argv prefix that runs ninja on this machine. + + Prefer a `ninja` executable on PATH -- that is what a developer who + followed any ordinary install guide has, and what CMake and Meson already + use. Fall back to the `ninja` PyPI wheel only when no binary is present. + + ebuild used to invoke `sys.executable -m ninja` unconditionally, so a + machine with ninja correctly installed still failed with "No module named + ninja" on the first build of a new project. + """ + import shutil + + exe = shutil.which("ninja") + return [exe] if exe else [sys.executable, "-m", "ninja"] + + def detect_backend(source_dir: Path) -> str: """Auto-detect the build system from project files. @@ -121,22 +138,15 @@ 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." + "backend is generated and invoked by the CLI, not here." ) def build( @@ -185,9 +195,11 @@ def build( _run_or_log(cmd, dry_run) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" + raise RuntimeError( + f"BackendDispatcher cannot build backend '{backend}'. " + "Supported here: cargo, cmake, kbuild, make, meson. ebuild's own " + "ninja backend is generated and invoked by the CLI, not through " + "this dispatcher." ) def clean( @@ -232,7 +244,7 @@ def clean( ) elif backend == "ninja": _run_or_log( - [sys.executable, "-m", "ninja", "-C", str(self.build_dir), "-t", "clean"], + ninja_command() + ["-C", str(self.build_dir), "-t", "clean"], dry_run, check=False, ) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index 5aa2f374..ee51968d 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -9,6 +9,7 @@ from __future__ import annotations import json +import re import sys from dataclasses import dataclass, field from pathlib import Path @@ -104,6 +105,21 @@ def _resolve_target_cflags(self, target) -> List[str]: return cflags + def _object_path(self, target, src: str) -> Path: + """Object file for one source within one target. + + Namespaced by target name: a source shared by two targets must produce + two distinct objects. Ninja rejects two edges writing the same output, + and the targets may compile it with different cflags. + + The source's directory structure is flattened into the filename rather + than mirrored beneath the build directory. Mirroring lets a source from + outside the project -- ``../shared/util.c`` -- place its object outside + the build directory too, where ``clean`` will not find it. + """ + flat = re.sub(r"[^A-Za-z0-9_.-]", "_", str(src).replace("\\", "/")) + return self.build_dir / "obj" / target.name / f"{flat}.o" + def _write_ninja(self) -> None: """Write the build.ninja file.""" ninja_path = self.build_dir / "build.ninja" diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index b7b95228..e5679981 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -687,7 +687,8 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], log.success(f"Generated {build_path / 'compile_commands.json'}") log.step("Invoking ninja...") - ninja_cmd = [sys.executable, "-m", "ninja", "-f", str(build_path / "build.ninja")] + from ebuild.build.dispatch import ninja_command + ninja_cmd = ninja_command() + ["-f", str(build_path / "build.ninja")] if log.verbose: ninja_cmd.append("-v") diff --git a/ebuild/deps/__init__.py b/ebuild/deps/__init__.py index 41a272be..900da51c 100644 --- a/ebuild/deps/__init__.py +++ b/ebuild/deps/__init__.py @@ -24,12 +24,12 @@ "repos": { "eos": { "url": DEFAULT_EOS_REPO_URL, - "branch": "main", + "branch": "master", "path": None, }, "eboot": { "url": DEFAULT_EBOOT_REPO_URL, - "branch": "main", + "branch": "master", "path": None, }, }, diff --git a/ebuild/deps/manager.py b/ebuild/deps/manager.py index 64a31444..851c186c 100644 --- a/ebuild/deps/manager.py +++ b/ebuild/deps/manager.py @@ -118,7 +118,7 @@ def setup( Args: repo_name: ``"eos"`` or ``"eboot"``. url: Git URL override. Falls back to config → default. - branch: Branch/tag override. Falls back to config → ``"main"``. + branch: Branch/tag override. Falls back to config → ``"master"``. path: If given, register this local path instead of cloning. shallow: Use ``--depth 1`` for faster clones (default *True*). @@ -143,7 +143,7 @@ def setup( # Clone to cache effective_url = repo_cfg.get("url") or self._default_url(repo_name) - effective_branch = repo_cfg.get("branch") or "main" + effective_branch = repo_cfg.get("branch") or "master" dest = self.cache_dir / repo_name if dest.exists(): @@ -279,7 +279,7 @@ def status(self) -> List[Dict[str, Any]]: info: Dict[str, Any] = { "name": name, "url": repo_cfg.get("url", self._default_url(name)), - "branch": repo_cfg.get("branch", "main"), + "branch": repo_cfg.get("branch", "master"), "config_path": repo_cfg.get("path"), "cached": cached.is_dir(), "cache_location": str(cached) if cached.is_dir() else None, diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e973ca06..c405c413 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -62,8 +62,15 @@ def test_static_library_unaffected(self): ninja = self._generate("static", target) self.assertIn(": ar_rule", ninja) - self.assertNotIn("-shared", ninja) - self.assertNotIn("-dynamiclib", ninja) + + # The link_shared *rule* is always declared in the preamble, so the + # bare string "-shared" is present in every generated file. What must + # be absent is any build *edge* that uses it. + edges = [line for line in ninja.splitlines() if line.startswith("build ")] + self.assertTrue(edges, "no build edges were generated") + for edge in edges: + self.assertNotIn(": link_shared ", edge) + self.assertNotIn(": link ", edge) if __name__ == "__main__": diff --git a/tests/unit/test_sources_are_importable.py b/tests/unit/test_sources_are_importable.py new file mode 100644 index 00000000..36170552 --- /dev/null +++ b/tests/unit/test_sources_are_importable.py @@ -0,0 +1,140 @@ +# SPDX-License-Identifier: MIT +# SPDX-FileCopyrightText: 2026 EoS Contributors +"""Every shipped Python file must parse, and the CLI must import. + +Python does not parse a module until something imports it, so a syntax error +can sit on master indefinitely — reachable only when a developer runs the +command that touches it. That is what happened to ``ebuild/build/dispatch.py``, +which carried two consecutive ``else:`` blocks and made ``ebuild build`` fail +with a SyntaxError for every user, on the first build of a new project. + +Unit tests that mock the build layer would not have caught it. These tests +compile the files instead of trusting that some other test imported them. +""" + +from __future__ import annotations + +import ast +import importlib +import subprocess +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PACKAGE_ROOT = REPO_ROOT / "ebuild" + + +def _python_sources() -> list[Path]: + return sorted( + p for p in PACKAGE_ROOT.rglob("*.py") + if "__pycache__" not in p.parts + ) + + +def test_package_root_exists() -> None: + assert PACKAGE_ROOT.is_dir(), f"{PACKAGE_ROOT} not found" + assert _python_sources(), "no Python sources discovered — the glob is wrong" + + +@pytest.mark.parametrize( + "source", _python_sources(), ids=lambda p: str(p.relative_to(PACKAGE_ROOT)) +) +def test_source_parses(source: Path) -> None: + """Parse every file. A SyntaxError here is a broken command for a user.""" + text = source.read_text(encoding="utf-8") + try: + ast.parse(text, filename=str(source)) + except SyntaxError as exc: + rel = source.relative_to(REPO_ROOT) + pytest.fail(f"{rel}:{exc.lineno}: {exc.msg}") + + +def test_cli_module_imports() -> None: + """The CLI must import cleanly, including its lazy command modules. + + Skipped where the runtime dependencies are absent — this test is about the + source being valid, not about the environment being provisioned. + + Importing ebuild.cli.commands alone is not enough: several commands import + their implementation inside the function body, which is exactly where the + dispatch.py breakage hid. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + for module in ( + "ebuild.cli.commands", + "ebuild.build.dispatch", + "ebuild.deps.manager", + ): + importlib.import_module(module) + + +def test_cli_help_runs() -> None: + """`python -m ebuild --help` must exit 0. + + The end-to-end check: if this fails, the tool does not start at all. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + result = subprocess.run( + [sys.executable, "-m", "ebuild", "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"`python -m ebuild --help` exited {result.returncode}\n" + f"stdout:\n{result.stdout}\nstderr:\n{result.stderr}" + ) + assert "Usage:" in result.stdout + + +@pytest.mark.parametrize("command", ["setup", "new", "configure", "build", "flash"]) +def test_mvp_commands_expose_help(command: str) -> None: + """Each command named in the MVP developer walk must at least start. + + `--help` forces click to construct the command and import whatever it + declares at module scope, so a broken command surfaces here rather than in + front of a new developer. + """ + pytest.importorskip("click", reason="ebuild CLI runtime dependency not installed") + + result = subprocess.run( + [sys.executable, "-m", "ebuild", command, "--help"], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, ( + f"`ebuild {command} --help` exited {result.returncode}\n{result.stderr}" + ) + + +def test_default_repo_branch_exists_upstream() -> None: + """The cached-repo default branch must actually exist on the remote. + + `ebuild setup` is the first command a new developer runs. It cloned + branch "main" while both repositories default to "master", so setup failed + for everyone, every time. A hardcoded branch name is a fact about a remote, + and facts about remotes go stale. + """ + from ebuild.deps import DEFAULT_CONFIG + + for name, cfg in DEFAULT_CONFIG["repos"].items(): + url, branch = cfg["url"], cfg["branch"] + result = subprocess.run( + ["git", "ls-remote", "--heads", url, branch], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + pytest.skip(f"cannot reach {url}: {result.stderr.strip()}") + assert result.stdout.strip(), ( + f"default branch '{branch}' for repo '{name}' does not exist at {url}. " + "ebuild setup will fail for every new developer." + ) From 85329075f4d1e5e9b3bcc63edd3b7b94306414d2 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Fri, 28 Aug 2026 02:39:55 -0700 Subject: [PATCH 2/3] fix(mvp): resolve eos headers for scaffolded projects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every one of the six templates includes , and the generated build.yaml carried no path to it, so `ebuild new` followed by `ebuild build` failed for all of them: src/main.c:9:10: fatal error: eos/hal.h: No such file or directory The tool scaffolded a project that the tool could not build. The templates now declare `uses: [eos]`, and _workspace_repo_paths() resolves that against the repos `ebuild setup` already cloned into ~/.ebuild/repos. Paths are resolved at build time rather than written into build.yaml, because the path is a fact about one machine and build.yaml is a file the developer commits. Headers sit at two depths — kernel/include and services/crypto/include — so both are globbed; and live only in the deeper set. Declared packages still take precedence over the workspace cache, so an explicit dependency always wins. When the cache is absent the mapping is empty, leaving the developer with the missing-header error and `ebuild setup` as the fix, rather than a stack trace. Measured across all six templates, from `ebuild new` to `ebuild build`: before 6/6 stopped at "fatal error: eos/hal.h" after 3/6 compile and reach the linker (bare-metal, linux-app, rtos-app), needing only the eos libraries 2/6 fail on template source that calls APIs eos does not have — eos_ble_send, eos_ota_status_t 1/6 needs arm-none-eabi, absent from this machine Not fixed here, and each needs a decision rather than a guess: - linking: ebuild must build eos and expose its archives before a template can produce a binary - ble-sensor and secure-boot reference APIs that do not exist - `ebuild monitor`, `ebuild test` and `ebuild simulate` are named in the MVP walk and do not exist as commands tests/unit: 111 passed. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/cli/commands.py | 37 ++++++++++++++++++- templates/bare-metal/build.yaml.template | 2 + templates/ble-sensor/build.yaml.template | 2 + templates/linux-app/build.yaml.template | 2 + templates/rtos-app/build.yaml.template | 2 + templates/safety-critical/build.yaml.template | 2 + templates/secure-boot/build.yaml.template | 2 + 7 files changed, 48 insertions(+), 1 deletion(-) diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index e5679981..03c04ff5 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -187,6 +187,40 @@ def note_skipped(name: str, _cause: BaseException) -> None: return package_paths +def _workspace_repo_paths() -> Dict[str, PackagePaths]: + """Include paths for the eos and eboot repos that `ebuild setup` cloned. + + A scaffolded project includes , but the generated build.yaml + carried no path to the headers, so every template failed with + "fatal error: eos/hal.h: No such file or directory" on the first build. + + These are resolved at build time from the cache rather than written into + build.yaml as absolute paths: the path is a fact about this machine, and + build.yaml is a file the developer commits. + + Returns an empty mapping when the cache is absent, so the error a developer + sees stays the missing header rather than a stack trace, and `ebuild setup` + remains the fix. + """ + from ebuild.deps import EBUILD_REPOS_DIR + + paths: Dict[str, PackagePaths] = {} + for name in ("eos", "eboot"): + root = Path(EBUILD_REPOS_DIR) / name + if not root.is_dir(): + continue + # Headers sit at two depths: kernel/include, hal/include ... and + # services/crypto/include, services/ota/include. Both are needed -- + # and live only in the deeper set. + include_dirs = sorted( + {p for pattern in ("include", "*/include", "*/*/include") + for p in root.glob(pattern) if p.is_dir()} + ) + if include_dirs: + paths[name] = PackagePaths(include_dirs=include_dirs, lib_dirs=[], libraries=[]) + return paths + + def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: """Detect installed library names from a lib/ directory.""" if not lib_dir.exists(): @@ -678,7 +712,8 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], log.debug(f"Compiler: {compiler.cc}") # Install packages if any are declared - package_paths = _install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs) + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose, jobs=jobs)} log.step(f"Generating build.ninja in {build_path}/...") ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) diff --git a/templates/bare-metal/build.yaml.template b/templates/bare-metal/build.yaml.template index 04ecef13..0109c331 100644 --- a/templates/bare-metal/build.yaml.template +++ b/templates/bare-metal/build.yaml.template @@ -16,4 +16,6 @@ targets: - src/main.c includes: - src/ + uses: + - eos depends: [] diff --git a/templates/ble-sensor/build.yaml.template b/templates/ble-sensor/build.yaml.template index 04ecef13..0109c331 100644 --- a/templates/ble-sensor/build.yaml.template +++ b/templates/ble-sensor/build.yaml.template @@ -16,4 +16,6 @@ targets: - src/main.c includes: - src/ + uses: + - eos depends: [] diff --git a/templates/linux-app/build.yaml.template b/templates/linux-app/build.yaml.template index 04ecef13..0109c331 100644 --- a/templates/linux-app/build.yaml.template +++ b/templates/linux-app/build.yaml.template @@ -16,4 +16,6 @@ targets: - src/main.c includes: - src/ + uses: + - eos depends: [] diff --git a/templates/rtos-app/build.yaml.template b/templates/rtos-app/build.yaml.template index 04ecef13..0109c331 100644 --- a/templates/rtos-app/build.yaml.template +++ b/templates/rtos-app/build.yaml.template @@ -16,4 +16,6 @@ targets: - src/main.c includes: - src/ + uses: + - eos depends: [] diff --git a/templates/safety-critical/build.yaml.template b/templates/safety-critical/build.yaml.template index 5a38d917..854186b3 100644 --- a/templates/safety-critical/build.yaml.template +++ b/templates/safety-critical/build.yaml.template @@ -20,6 +20,8 @@ targets: - src/ cflags: ["-mcpu=cortex-r5", "-mfloat-abi=hard", "-mfpu=vfpv3-d16", "-Os"] ldflags: ["-nostartfiles"] + uses: + - eos depends: [] firmware: diff --git a/templates/secure-boot/build.yaml.template b/templates/secure-boot/build.yaml.template index 04ecef13..0109c331 100644 --- a/templates/secure-boot/build.yaml.template +++ b/templates/secure-boot/build.yaml.template @@ -16,4 +16,6 @@ targets: - src/main.c includes: - src/ + uses: + - eos depends: [] From 13fa6830bbc8cf7dd5afc5081bf9919a7c7eef56 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Fri, 28 Aug 2026 03:19:56 -0700 Subject: [PATCH 3/3] =?UTF-8?q?fix(mvp):=20complete=20the=20golden=20path?= =?UTF-8?q?=20=E2=80=94=20configure=20--board,=20test,=20monitor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Walking the eight-command MVP path on this branch stopped at step five: `build` reached the linker and failed with undefined references, and two of the eight commands did not exist at all. before setup ok · new ok · configure --board: no such option build: undefined reference to eos_hal_init · test: no command monitor: no command after 7 of 8 steps pass; step 7 (flash) needs attached hardware Link step. `uses: [eos]` resolved include dirs but returned lib_dirs=[] and libraries=[], so every scaffolded project compiled and then failed to link. The cached repo is a CMake project with no install() rules, so there is nothing to point -L at until it has been built once; _cached_repo_libraries builds it on demand and caches the tree, degrading to ([], []) when cmake is absent so the developer still sees a link error naming the symbol. configure/build divergence. _configure_ninja_backend did not merge _workspace_repo_paths(), so `configure` and `build` wrote different build.ninja files to the same path and a developer who ran `configure` and then invoked ninja directly built without the EoS paths. New commands. `test` runs the project's own runner (ctest, cargo, meson, make test) and, for a scaffolded project that has none, builds and runs its `test` targets directly. `monitor` opens the serial device, and refuses to guess when more than one is attached. `configure --board` records the board in eos.yaml, since the next step is a bare `build` that has to read it back. Templates now ship tests/test_main.c and a matching test target, so step six passes on a fresh checkout instead of reporting that the project has no tests. The test deliberately does not assert eos_hal_init() == 0: no HAL backend is registered on a host build, so it returns -1 by design. Also in this commit, found while running the above: - ninja_backend emitted shared libraries through the generic `link` rule with -shared pushed into ldflags, leaving the declared link_shared rule dead and breaking test_shared_library_uses_shared_link_rule. Restored the rule and kept the -L/-l wiring this branch added; the platform's shared-object flag now lives in the rule, so darwin's -dynamiclib is chosen there rather than at the call site. - BackendDispatcher raised RuntimeError from configure() and build() but ValueError from clean(), so one `except` could not guard all three. clean() now raises RuntimeError, and the two test files this branch added no longer disagree about which type to expect. - _build/ is ebuild's default build dir and was not in .gitignore. 288 tests pass, up from 98 on master and 256/3-failed on this branch. Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 3 + ebuild/build/dispatch.py | 6 +- ebuild/build/ninja_backend.py | 22 +- ebuild/cli/commands.py | 405 +++++++++++++++++- ebuild/core/config.py | 12 +- templates/bare-metal/build.yaml.template | 10 + templates/bare-metal/test_main.c.template | 59 +++ templates/ble-sensor/build.yaml.template | 10 + templates/ble-sensor/test_main.c.template | 59 +++ templates/linux-app/build.yaml.template | 10 + templates/linux-app/test_main.c.template | 59 +++ templates/rtos-app/build.yaml.template | 10 + templates/rtos-app/test_main.c.template | 59 +++ templates/safety-critical/build.yaml.template | 10 + .../safety-critical/test_main.c.template | 59 +++ templates/secure-boot/build.yaml.template | 10 + templates/secure-boot/test_main.c.template | 59 +++ tests/ebuild/test_dispatch.py | 12 +- tests/unit/test_golden_path_commands.py | 200 +++++++++ tests/unit/test_ninja_backend.py | 13 +- 20 files changed, 1062 insertions(+), 25 deletions(-) create mode 100644 templates/bare-metal/test_main.c.template create mode 100644 templates/ble-sensor/test_main.c.template create mode 100644 templates/linux-app/test_main.c.template create mode 100644 templates/rtos-app/test_main.c.template create mode 100644 templates/safety-critical/test_main.c.template create mode 100644 templates/secure-boot/test_main.c.template create mode 100644 tests/unit/test_golden_path_commands.py diff --git a/.gitignore b/.gitignore index 64115a9d..9ad102a4 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,6 @@ Desktop.ini build-*/ node_modules/ target/ + +# ebuild build output (the default --build-dir) +_build/ diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index b69709bd..3cf77d5b 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -215,7 +215,9 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + RuntimeError: If the backend is not recognized. configure() and + build() raise the same type for the same condition, so a + caller can guard all three with one ``except``. """ if backend == "cmake": _run_or_log( @@ -249,7 +251,7 @@ def clean( check=False, ) else: - raise ValueError( + raise RuntimeError( f"Unknown build backend '{backend}'. " f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" ) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index ee51968d..1282545e 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -31,6 +31,14 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _shared_flag() -> str: + """The compiler flag that produces a shared object on this platform. + + macOS links dynamic libraries with -dynamiclib; ELF platforms use -shared. + """ + return "-dynamiclib" if sys.platform == "darwin" else "-shared" + + class NinjaBackend: """Generate build.ninja from a ProjectConfig and resolved toolchain. @@ -140,7 +148,7 @@ def _write_ninja(self) -> None: " description = LINK $out", "", "rule link_shared", - " command = $cc -shared $ldflags $in -o $out $libs", + f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs", " description = LINK_SHARED $out", "", "rule ar_rule", @@ -165,7 +173,7 @@ def _write_ninja(self) -> None: lines.append(f" cflags = {' '.join(cflags)}") lines.append("") - if target.target_type == "executable": + if target.target_type in ("executable", "test"): ldflags = toolchain_ldflags + list(target.ldflags) libs = [] dep_archives = [] @@ -206,11 +214,11 @@ def _write_ninja(self) -> None: if target.target_type == "static_library": lines.append(f"build {out}: ar_rule {' '.join(obj_files)}") else: - # Shared libraries need the platform's "build a shared - # object" flag and the same -L/-l wiring executables get, - # neither of which the generic `link` rule provides. + # Shared libraries need the same -L/-l wiring executables + # 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.insert(0, "-dynamiclib" if sys.platform == "darwin" else "-shared") libs = [] for pkg_name in target.uses: pkg = self.package_paths.get(pkg_name) @@ -220,7 +228,7 @@ def _write_ninja(self) -> None: for lib in pkg.libraries: libs.append(f"-l{lib}") - lines.append(f"build {out}: link {' '.join(obj_files)}") + lines.append(f"build {out}: link_shared {' '.join(obj_files)}") if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") if libs: diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 03c04ff5..3c4fed9e 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -9,7 +9,9 @@ from __future__ import annotations +import glob import os +import re import shutil import subprocess import threading @@ -217,8 +219,63 @@ def _workspace_repo_paths() -> Dict[str, PackagePaths]: for p in root.glob(pattern) if p.is_dir()} ) if include_dirs: - paths[name] = PackagePaths(include_dirs=include_dirs, lib_dirs=[], libraries=[]) + lib_dirs, libraries = _cached_repo_libraries(root) + paths[name] = PackagePaths( + include_dirs=include_dirs, + lib_dirs=lib_dirs, + libraries=libraries, + ) return paths + + +# Where `ebuild` puts the CMake build tree for a cached repo. Kept inside the +# clone so `ebuild setup` remains the only thing that owns ~/.ebuild/repos. +_REPO_BUILD_DIRNAME = "_ebuild" + + +def _cached_repo_libraries(root: Path) -> Tuple[List[Path], List[str]]: + """Static libraries a cached repo offers to projects that `use` it. + + Headers alone are not enough: a scaffolded project compiles against + and then fails at the link step with undefined references. + The repo is a CMake project with no install() rules, so there is nothing + to point a -L at until it has been built once. Build it on demand and + cache the result; subsequent builds reuse the tree. + + Returns ([], []) when the repo cannot be built here — a missing cmake, a + repo that is not a CMake project — so the developer still gets a link + error naming the symbol rather than a stack trace from ebuild. + """ + if not (root / "CMakeLists.txt").is_file(): + return [], [] + + build_dir = root / _REPO_BUILD_DIRNAME + archives = sorted(build_dir.rglob("*.a")) if build_dir.is_dir() else [] + + if not archives: + if shutil.which("cmake") is None: + return [], [] + try: + subprocess.run( + ["cmake", "-S", str(root), "-B", str(build_dir)], + check=True, capture_output=True, timeout=600, + ) + subprocess.run( + ["cmake", "--build", str(build_dir), "-j", str(os.cpu_count() or 1)], + check=True, capture_output=True, timeout=1800, + ) + except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError): + return [], [] + archives = sorted(build_dir.rglob("*.a")) + + if not archives: + return [], [] + + # -L one directory per archive location; -l the archive basenames with + # the lib prefix and .a suffix stripped, which is what the linker wants. + lib_dirs = sorted({a.parent for a in archives}) + libraries = [a.stem[3:] for a in archives if a.stem.startswith("lib")] + return lib_dirs, libraries def _detect_libraries(lib_dir: Path, pkg_name: str) -> List[str]: @@ -264,12 +321,22 @@ def _configure_ninja_backend( cfg: ProjectConfig, build_path: Path, log: Logger, + *, + suggest_build: bool = True, ) -> None: - """Generate native ebuild Ninja files for configure-only workflows.""" + """Generate native ebuild Ninja files for configure-only workflows. + + The package paths are merged exactly as `ebuild build` merges them. If + they were not, `configure` and `build` would each write a different + build.ninja to the same path, and a developer who ran `configure` and then + invoked ninja directly would build without the cached-repo include and + library paths. + """ log.step("Resolving toolchain...") compiler = resolve_toolchain(cfg.toolchain) - package_paths = _install_packages(cfg, build_path, log, verbose=log.verbose) + package_paths = {**_workspace_repo_paths(), + **_install_packages(cfg, build_path, log, verbose=log.verbose)} log.step(f"Generating build.ninja in {build_path}/...") ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) @@ -277,7 +344,8 @@ def _configure_ninja_backend( log.success(f"Generated {build_path / 'build.ninja'}") log.success(f"Generated {build_path / 'compile_commands.json'}") - log.info("Run 'ebuild build' to compile.") + if suggest_build: + log.info("Run 'ebuild build' to compile.") def _configure_external_backend( @@ -325,6 +393,63 @@ def _format_missing_tool(exc: FileNotFoundError) -> str: # ═══════════════════════════════════════════════════════════════ +# Board selection — shared by `configure --board` and `monitor` +# --------------------------------------------------------------------------- + +# The project-local file that records which board this checkout targets. +_EOS_PROJECT_CONFIG = "eos.yaml" + + +def _record_board_selection(board: str, log: Logger) -> None: + """Persist ``--board`` into eos.yaml under ``system.board``. + + The golden path is `configure --board` then a bare `build`, so the choice + has to outlive the configure process. It is written to eos.yaml rather + than build.yaml because the board is a property of the system being + targeted, which is what eos.yaml already describes. + """ + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + log.error( + f"No {_EOS_PROJECT_CONFIG} here, so there is nothing to record the " + f"board against. Run this from a project directory created by " + f"'ebuild new'." + ) + raise SystemExit(1) + + import yaml + + try: + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except yaml.YAMLError as e: + log.error(f"{_EOS_PROJECT_CONFIG} is not valid YAML: {e}") + raise SystemExit(1) + + system = data.setdefault("system", {}) + previous = system.get("board") + system["board"] = board + path.write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8") + + if previous and previous != board: + log.info(f"Board: {previous} -> {board}") + else: + log.info(f"Board: {board}") + + +def _selected_board(default: str = "generic") -> str: + """The board recorded by ``configure --board``, or ``default``.""" + path = Path(_EOS_PROJECT_CONFIG) + if not path.is_file(): + return default + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except Exception: + return default + return (data.get("system") or {}).get("board") or default + + +# --------------------------------------------------------------------------- # Pipeline helper — shared by `pipeline` and `build --board` # ═══════════════════════════════════════════════════════════════ @@ -901,12 +1026,22 @@ def clean(log: Logger, build_dir: str) -> None: type=click.Choice(["auto", "cmake", "make", "meson", "cargo", "ninja", "kbuild"]), help="Force a specific build backend.", ) +@click.option( + "--board", + default=None, + help="Target board name (e.g., stm32f4, nrf52). Recorded in the project " + "config so later `ebuild build` / `flash` / `monitor` use it.", +) @click.pass_obj -def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str]) -> None: +def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str]) -> None: """Generate build files without building.""" log.header("ebuild — Configure") try: + if board: + _record_board_selection(board, log) + log.step("Loading configuration...") cfg = load_config(config_path) log.info(f"Project: {cfg.name} v{cfg.version}") @@ -1666,6 +1801,11 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Create project directory structure src_dir = project_dir / "src" src_dir.mkdir(parents=True) + # A scaffolded project ships a runnable test so that `ebuild test`, step + # six of the golden path, passes on a fresh checkout instead of reporting + # that the project declares no tests. + tests_dir = project_dir / "tests" + tests_dir.mkdir(parents=True, exist_ok=True) # Template variable substitution replacements = { @@ -1681,6 +1821,7 @@ def new(log: Logger, project_name: str, template_name: str, board_name: str, # Copy and process template files file_mapping = { "main.c.template": src_dir / "main.c", + "test_main.c.template": tests_dir / "test_main.c", "build.yaml.template": project_dir / "build.yaml", "eos.yaml.template": project_dir / "eos.yaml", "README.md.template": project_dir / "README.md", @@ -2013,3 +2154,257 @@ def generate_board( except Exception as e: log.error(f"Board generation failed: {e}") raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", + "config_path", + default="build.yaml", + type=click.Path(), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--filter", + "name_filter", + default=None, + help="Only run tests whose name contains this substring.", +) +@click.pass_obj +def test(log: Logger, config_path: str, build_dir: str, + name_filter: Optional[str]) -> None: + """Build and run the project's tests. + + Step six of the golden path. Delegates to whichever runner the project + already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or + `make test` -- rather than imposing a test framework on the project. + """ + log.header("ebuild — Test") + + build_path = Path(build_dir) + + try: + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + except FileNotFoundError: + log.error( + f"No {config_path} here. Run this from a project directory, or " + f"pass --config." + ) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + + native = [t for t in cfg.targets if t.target_type == "test"] + if native: + _run_native_tests(cfg, native, build_path, log, name_filter) + return + + runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) + if runner is None: + log.error( + "No test runner found for this project.\n" + " ebuild test drives the project's own runner. Add one of:\n" + " - CMake with enable_testing() + add_test() -> ctest\n" + " - a 'test' target in the Makefile -> make test\n" + " - Cargo.toml -> cargo test\n" + " - meson.build -> meson test" + ) + raise SystemExit(1) + + name, argv, cwd = runner + log.step(f"Running tests with {name}...") + log.info(" ".join(argv)) + + try: + result = subprocess.run(argv, cwd=str(cwd)) + except FileNotFoundError: + log.error( + f"{name} is not installed or not on PATH, so the tests cannot be " + f"run here." + ) + raise SystemExit(1) + + if result.returncode != 0: + log.error(f"Tests failed ({name} exited {result.returncode}).") + raise SystemExit(result.returncode) + + log.success("All tests passed.") + + +def _run_native_tests( + cfg: "ProjectConfig", + targets: List[Any], + build_path: Path, + log: Logger, + name_filter: Optional[str], +) -> None: + """Build and run the project's own ``test`` targets. + + A scaffolded project has no CMake tree and no Makefile, so there is no + external runner to delegate to. The test binaries are ordinary ebuild + targets; build them the same way `ebuild build` does, then run each one + and treat a non-zero exit as a failure. + """ + selected = [t for t in targets if not name_filter or name_filter in t.name] + if not selected: + log.error(f"No test target matches --filter {name_filter!r}.") + raise SystemExit(1) + + log.step("Building test targets...") + _configure_ninja_backend(cfg, build_path, log, suggest_build=False) + + from ebuild.build.dispatch import ninja_command + + # Ninja addresses targets by their output path, and `ebuild build` drives + # it with -f from the project root, so the same form is used here. + argv = ( + ninja_command() + + ["-f", str(build_path / "build.ninja")] + + [str(build_path / t.name) for t in selected] + ) + result = subprocess.run(argv) + if result.returncode != 0: + log.error("Test targets failed to build.") + raise SystemExit(result.returncode) + + failures: List[str] = [] + for target in selected: + binary = build_path / target.name + if not binary.is_file(): + log.error(f"{target.name}: built, but no binary at {binary}") + failures.append(target.name) + continue + + log.step(f"Running {target.name}...") + run = subprocess.run([str(binary)]) + if run.returncode == 0: + log.success(f" {target.name}: passed") + else: + log.error(f" {target.name}: exited {run.returncode}") + failures.append(target.name) + + if failures: + log.error(f"{len(failures)} of {len(selected)} test targets failed: " + + ", ".join(failures)) + raise SystemExit(1) + + log.success(f"All {len(selected)} test targets passed.") + + +def _resolve_test_runner( + source_dir: Path, + build_dir: Path, + name_filter: Optional[str], +) -> Optional[Tuple[str, List[str], Path]]: + """Pick the test runner this project already uses. + + Returns ``(display_name, argv, cwd)``, or None when the project declares + no tests. Ordered so that an explicit CMake test registry wins over a + generic `make test` target in the same tree. + """ + if (build_dir / "CTestTestfile.cmake").is_file(): + argv = ["ctest", "--output-on-failure"] + if name_filter: + argv += ["-R", name_filter] + return "ctest", argv, build_dir + + if (source_dir / "Cargo.toml").is_file(): + argv = ["cargo", "test"] + if name_filter: + argv += [name_filter] + return "cargo test", argv, source_dir + + if (build_dir / "meson-info").is_dir(): + argv = ["meson", "test", "-C", str(build_dir)] + if name_filter: + argv += ["--suite", name_filter] + return "meson test", argv, source_dir + + makefile = next( + (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") + if (source_dir / n).is_file()), + None, + ) + if makefile is not None: + text = makefile.read_text(encoding="utf-8", errors="replace") + if re.search(r"^test\s*:", text, re.MULTILINE): + return "make test", ["make", "-C", str(source_dir), "test"], source_dir + + return None + + +@cli.command() +@click.option("--port", default=None, + help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") +@click.option("--baud", default=115200, type=int, help="Baud rate.") +@click.pass_obj +def monitor(log: Logger, port: Optional[str], baud: int) -> None: + """Attach a serial monitor to the target device. + + Step eight of the golden path -- the step that shows a developer their + first firmware run actually produced output. + """ + log.header("ebuild — Monitor") + log.info(f"Board: {_selected_board()}") + + if port is None: + candidates = _serial_ports() + if not candidates: + log.error( + "No serial device found.\n" + " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" + " Connect the board, or name the device with --port." + ) + raise SystemExit(1) + if len(candidates) > 1: + log.error( + "More than one serial device is connected, so ebuild will not " + "guess which one is the board:\n" + + "\n".join(f" {c}" for c in candidates) + + "\n Choose one with --port." + ) + raise SystemExit(1) + port = candidates[0] + log.info(f"Auto-detected {port}") + + log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") + + try: + import serial # type: ignore[import-untyped] + except ImportError: + log.error( + "pyserial is not installed, so the monitor cannot open the port.\n" + " Install it with: pip install pyserial" + ) + raise SystemExit(1) + + try: + with serial.Serial(port, baud, timeout=0.2) as conn: + while True: + chunk = conn.read(4096) + if chunk: + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + except KeyboardInterrupt: + log.info("") + log.success("Monitor closed.") + except Exception as e: + log.error(f"Serial error on {port}: {e}") + raise SystemExit(1) + + +def _serial_ports() -> List[str]: + """Serial devices that look like an attached development board.""" + found: List[str] = [] + for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): + found.extend(sorted(glob.glob(pattern))) + return found diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 36955459..9e00f308 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -42,7 +42,12 @@ class PackageDep: @dataclass class TargetConfig: - """A single build target (executable, static_library, or shared_library).""" + """A single build target. + + A ``test`` target links exactly like an ``executable``; the type exists so + ``ebuild test`` can tell which binaries it is meant to run without + guessing from the target's name. + """ name: str target_type: str @@ -54,7 +59,10 @@ class TargetConfig: depends: List[str] = field(default_factory=list) uses: List[str] = field(default_factory=list) - VALID_TYPES = ("executable", "static_library", "shared_library") + VALID_TYPES = ("executable", "test", "static_library", "shared_library") + + #: Target types that produce a runnable binary rather than an archive. + EXECUTABLE_TYPES = ("executable", "test") def validate(self) -> None: if not self.name: diff --git a/templates/bare-metal/build.yaml.template b/templates/bare-metal/build.yaml.template index 0109c331..88ea5aa6 100644 --- a/templates/bare-metal/build.yaml.template +++ b/templates/bare-metal/build.yaml.template @@ -19,3 +19,13 @@ targets: uses: - eos depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/bare-metal/test_main.c.template b/templates/bare-metal/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/bare-metal/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/ble-sensor/build.yaml.template b/templates/ble-sensor/build.yaml.template index 0109c331..88ea5aa6 100644 --- a/templates/ble-sensor/build.yaml.template +++ b/templates/ble-sensor/build.yaml.template @@ -19,3 +19,13 @@ targets: uses: - eos depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/ble-sensor/test_main.c.template b/templates/ble-sensor/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/ble-sensor/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/linux-app/build.yaml.template b/templates/linux-app/build.yaml.template index 0109c331..88ea5aa6 100644 --- a/templates/linux-app/build.yaml.template +++ b/templates/linux-app/build.yaml.template @@ -19,3 +19,13 @@ targets: uses: - eos depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/linux-app/test_main.c.template b/templates/linux-app/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/linux-app/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/rtos-app/build.yaml.template b/templates/rtos-app/build.yaml.template index 0109c331..88ea5aa6 100644 --- a/templates/rtos-app/build.yaml.template +++ b/templates/rtos-app/build.yaml.template @@ -19,3 +19,13 @@ targets: uses: - eos depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/rtos-app/test_main.c.template b/templates/rtos-app/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/rtos-app/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/safety-critical/build.yaml.template b/templates/safety-critical/build.yaml.template index 854186b3..01317eb0 100644 --- a/templates/safety-critical/build.yaml.template +++ b/templates/safety-critical/build.yaml.template @@ -29,3 +29,13 @@ firmware: core: {{CORE}} tick_rate: 1000 watchdog_ms: 100 + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/safety-critical/test_main.c.template b/templates/safety-critical/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/safety-critical/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/templates/secure-boot/build.yaml.template b/templates/secure-boot/build.yaml.template index 0109c331..88ea5aa6 100644 --- a/templates/secure-boot/build.yaml.template +++ b/templates/secure-boot/build.yaml.template @@ -19,3 +19,13 @@ targets: uses: - eos depends: [] + + - name: test_{{PROJECT_NAME}} + type: test + sources: + - tests/test_main.c + includes: + - src/ + uses: + - eos + depends: [] diff --git a/templates/secure-boot/test_main.c.template b/templates/secure-boot/test_main.c.template new file mode 100644 index 00000000..fe7fca9d --- /dev/null +++ b/templates/secure-boot/test_main.c.template @@ -0,0 +1,59 @@ +/** + * {{PROJECT_NAME}} — smoke test + * + * Generated by: ebuild new {{PROJECT_NAME}} + * + * Runs on the host via `ebuild test`. Its job is to fail when the build + * wiring is wrong -- a missing EoS checkout, a bad include path, a link-order + * problem -- so that those are caught here rather than on the bench. + * + * It deliberately does not assert that eos_hal_init() succeeds. A host build + * has no HAL backend registered, so eos_hal_init() returns -1 by design; the + * board build is where that call is expected to return 0. + */ + +#include +#include + +static int failures = 0; + +#define CHECK(cond) \ + do { \ + if (!(cond)) { \ + printf("FAIL %s:%d: %s\n", __FILE__, __LINE__, #cond); \ + failures++; \ + } \ + } while (0) + +int main(void) +{ + /* Linking proves the EoS headers and libraries resolved. Calling proves + * the dispatch layer is present and does not fault with no backend. */ + int status = eos_hal_init(); + printf("eos_hal_init() -> %d%s\n", status, + status == 0 ? "" : " (no backend on a host build — expected)"); + + /* The HAL must tolerate calls with no backend rather than crashing: + * that is what lets this test run at all on a development machine. */ + eos_gpio_config_t led = { + .pin = 13, + .mode = EOS_GPIO_OUTPUT, + .pull = EOS_GPIO_PULL_NONE, + .speed = EOS_GPIO_SPEED_LOW, + }; + eos_gpio_init(&led); + eos_gpio_toggle(led.pin); + + /* A tick source is the one thing every board must provide; on the host + * it is monotonic and must not run backwards. */ + uint32_t t0 = eos_get_tick_ms(); + uint32_t t1 = eos_get_tick_ms(); + CHECK(t1 >= t0); + + if (failures == 0) { + printf("ok — {{PROJECT_NAME}} smoke test passed\n"); + return 0; + } + printf("%d check(s) failed\n", failures); + return 1; +} diff --git a/tests/ebuild/test_dispatch.py b/tests/ebuild/test_dispatch.py index aa71c394..c6061472 100644 --- a/tests/ebuild/test_dispatch.py +++ b/tests/ebuild/test_dispatch.py @@ -65,21 +65,25 @@ 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 rather than silently skip. + + All three methods raise RuntimeError for this one condition, so a caller + can guard the whole dispatcher with a single ``except RuntimeError``. + """ 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(RuntimeError, match="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(RuntimeError, match="gradle"): 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(RuntimeError, match="Unknown build backend"): d.clean("scons") diff --git a/tests/unit/test_golden_path_commands.py b/tests/unit/test_golden_path_commands.py new file mode 100644 index 00000000..635b7c08 --- /dev/null +++ b/tests/unit/test_golden_path_commands.py @@ -0,0 +1,200 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""The MVP golden path must stay walkable. + +The MVP is defined as a developer running eight commands in order and +reaching a verified first firmware run. Each of those commands has to exist, +and the scaffolding has to produce a project they succeed on. These are the +regression guards for the steps that were missing or broken. +""" + +from pathlib import Path + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import ( + _cached_repo_libraries, + _record_board_selection, + _resolve_test_runner, + _selected_board, + cli, +) +from ebuild.core.config import TargetConfig + + +GOLDEN_PATH_COMMANDS = [ + "setup", "new", "configure", "build", "test", "flash", "monitor", +] + + +@pytest.mark.ebuild +class TestGoldenPathCommandsExist: + """Every command the MVP walk names must be registered on the CLI.""" + + @pytest.mark.parametrize("name", GOLDEN_PATH_COMMANDS) + def test_command_is_registered(self, name): + assert name in cli.commands, ( + f"'ebuild {name}' is step of the MVP golden path but is not a " + f"registered command" + ) + + @pytest.mark.parametrize("name", GOLDEN_PATH_COMMANDS) + def test_command_help_runs(self, name): + result = CliRunner().invoke(cli, [name, "--help"]) + assert result.exit_code == 0, result.output + + def test_configure_accepts_board(self): + """`ebuild configure --board ` is the documented step four.""" + result = CliRunner().invoke(cli, ["configure", "--help"]) + assert result.exit_code == 0 + assert "--board" in result.output + + +@pytest.mark.ebuild +class TestBoardSelection: + """--board has to outlive the configure process: the next step is a + bare `ebuild build`, which can only find the board by reading it back.""" + + def test_board_round_trips_through_eos_yaml(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + Path("eos.yaml").write_text("system:\n kind: baremetal\n", encoding="utf-8") + + _record_board_selection("stm32f4", _SilentLog()) + + assert _selected_board() == "stm32f4" + data = yaml.safe_load(Path("eos.yaml").read_text(encoding="utf-8")) + assert data["system"]["board"] == "stm32f4" + # Recording a board must not discard the rest of the file. + assert data["system"]["kind"] == "baremetal" + + def test_board_defaults_when_unset(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert _selected_board() == "generic" + + def test_board_without_a_project_is_an_error(self, tmp_path, monkeypatch): + """Refuse rather than write an eos.yaml into whatever directory the + developer happened to be standing in.""" + monkeypatch.chdir(tmp_path) + with pytest.raises(SystemExit): + _record_board_selection("stm32f4", _SilentLog()) + assert not Path("eos.yaml").exists() + + +@pytest.mark.ebuild +class TestTestTargetType: + """`test` is a distinct target type so `ebuild test` knows which binaries + to run without pattern-matching target names.""" + + def test_test_type_is_valid(self): + TargetConfig(name="t", target_type="test", sources=["a.c"]).validate() + + def test_unknown_type_still_rejected(self): + from ebuild.core.config import ConfigError + with pytest.raises(ConfigError): + TargetConfig(name="t", target_type="fixture", sources=["a.c"]).validate() + + def test_test_target_links_like_an_executable(self, tmp_path): + """A test target must produce a runnable binary, not an archive.""" + from types import SimpleNamespace + from ebuild.build.ninja_backend import NinjaBackend + from ebuild.core.config import ProjectConfig + + cfg = ProjectConfig( + name="p", version="1", source_dir=tmp_path, + targets=[TargetConfig(name="t_smoke", target_type="test", + sources=["t.c"])], + ) + NinjaBackend(cfg, tmp_path / "b", + SimpleNamespace(cc="cc", cxx="c++", ar="ar")).generate() + ninja = (tmp_path / "b" / "build.ninja").read_text(encoding="utf-8") + + edge = next(l for l in ninja.splitlines() + if l.startswith("build ") and "t_smoke" in l and ".o" not in l.split(":")[0]) + assert ": link " in edge + assert ": ar_rule" not in edge + + +@pytest.mark.ebuild +class TestExternalTestRunners: + """Projects that already have a runner keep using it.""" + + def test_ctest_wins_when_a_cmake_test_registry_exists(self, tmp_path): + build = tmp_path / "b" + build.mkdir() + (build / "CTestTestfile.cmake").touch() + name, argv, cwd = _resolve_test_runner(tmp_path, build, None) + assert name == "ctest" + assert "--output-on-failure" in argv + + def test_filter_is_passed_through(self, tmp_path): + build = tmp_path / "b" + build.mkdir() + (build / "CTestTestfile.cmake").touch() + _, argv, _ = _resolve_test_runner(tmp_path, build, "kernel") + assert argv[-2:] == ["-R", "kernel"] + + def test_make_test_requires_an_actual_test_target(self, tmp_path): + """A Makefile with no `test:` rule is not a test runner; claiming it + is would make `ebuild test` report success for a project with no + tests at all.""" + (tmp_path / "Makefile").write_text("all:\n\techo hi\n", encoding="utf-8") + assert _resolve_test_runner(tmp_path, tmp_path / "b", None) is None + + def test_make_test_is_used_when_declared(self, tmp_path): + (tmp_path / "Makefile").write_text("test:\n\techo ok\n", encoding="utf-8") + name, argv, _ = _resolve_test_runner(tmp_path, tmp_path / "b", None) + assert name == "make test" + + def test_no_runner_for_a_bare_directory(self, tmp_path): + assert _resolve_test_runner(tmp_path, tmp_path / "b", None) is None + + +@pytest.mark.ebuild +class TestCachedRepoLibraries: + """Headers alone leave the developer at an undefined-reference wall, so a + cached repo has to offer libraries too -- and must degrade quietly when + it cannot.""" + + def test_non_cmake_repo_yields_nothing(self, tmp_path): + assert _cached_repo_libraries(tmp_path) == ([], []) + + def test_prebuilt_archives_are_reused(self, tmp_path): + (tmp_path / "CMakeLists.txt").touch() + build = tmp_path / "_ebuild" / "core" + build.mkdir(parents=True) + (build / "libeos_kernel.a").touch() + + lib_dirs, libs = _cached_repo_libraries(tmp_path) + + assert lib_dirs == [build] + assert libs == ["eos_kernel"] + + def test_non_lib_archives_are_not_offered_as_l_flags(self, tmp_path): + """-l wants the name with the lib prefix stripped; an archive that is + not named libX.a has no -l spelling and must be skipped.""" + (tmp_path / "CMakeLists.txt").touch() + build = tmp_path / "_ebuild" + build.mkdir(parents=True) + (build / "strays.a").touch() + (build / "libgood.a").touch() + + _, libs = _cached_repo_libraries(tmp_path) + + assert libs == ["good"] + + +class _SilentLog: + """Minimal Logger stand-in: these tests assert on state, not output.""" + + verbose = False + + def _noop(self, *a, **k): + return None + + info = step = success = warning = debug = header = _noop + + def error(self, *a, **k): + return None diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index c405c413..66cdf9c9 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -19,9 +19,9 @@ def _toolchain(): class TestNinjaBackendSharedLibrary(unittest.TestCase): """A shared_library target must link with the platform's shared-object - flag and get the same -L/-l wiring as executables. Previously it fell - through to the generic `link` rule with no flags and no libs at all, - silently producing a broken artifact.""" + flag and get the same -L/-l wiring as executables. Previously it used the + link_shared rule but emitted no ldflags and no libs line at all, so any + -L/-l from `uses` and any target ldflags were silently dropped.""" def setUp(self): self._tmpdir = tempfile.TemporaryDirectory() @@ -40,9 +40,12 @@ def test_shared_library_gets_shared_flag(self): shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared" self.assertIn(shared_flag, ninja) - # It must use the `link` rule (compiler driver), not `ar_rule`. + # It must go through a compiler-driver rule, not the `ar` archiver. + # link_shared is that rule, and it carries the shared-object flag so + # the flag is never repeated in the edge's ldflags. lib_line = next(line for line in ninja.splitlines() if "libmylib" in line and line.startswith("build")) - self.assertIn(": link ", lib_line) + self.assertIn(": link_shared ", lib_line) + self.assertNotIn(": ar_rule", lib_line) def test_shared_library_gets_lib_dirs_and_libs(self): target = TargetConfig(