Skip to content
27 changes: 26 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,32 @@
`cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1).

### Fixed

- **`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 `<name>.exe` on Windows), but `ebuild test`
rebuilt the path itself without that suffix, asked ninja to build
`<name>`, and then looked for that unsuffixed path. Ninja reported an
unknown target and the runner reported "built, but no binary". Both
steps now go through the same `executable_output_path()` the edge itself
uses (`ebuild/build/ninja_backend.py`, `ebuild/cli/commands.py`).
- **`ebuild package` now finds the Windows binary too.** It looked up
`<build_dir>/<name>` directly instead of through `executable_output_path()`,
so on Windows it reported "No built artifact" after a build that had
succeeded. Now uses the same helper as `ebuild test`
(`ebuild/cli/commands.py`).
- **`ebuild build`'s flash/RAM report went silent on Windows.**
`_report_footprint` also looked up `<build_dir>/<name>` directly, so on
Windows the artifact was never found and the function returned with no
diagnostic — the report just never appeared, with no indication it was
skipped rather than not applicable. Now uses `executable_output_path()`
and logs at debug level when it has nothing to measure
(`ebuild/cli/commands.py`).
- **`pytest` collection no longer aborts on Windows without gcc.**
`tests/ebuild/test_build_dir_resolution.py` evaluated
`subprocess.run(["gcc", "--version"])` in a `skipif`. When gcc is not
installed, Windows raises `FileNotFoundError` instead of a non-zero
returncode, which pytest treats as a collection ERROR and stops the
suite. The probe now uses `shutil.which` and catches `OSError`.
- **A path containing a space produced a silently wrong `build.ninja`.** Paths
were written into build statements unescaped, but Ninja ends the output list
at the first unescaped `:` and splits on unescaped spaces. A build directory
Expand Down
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,11 @@ no `add_test()` still produces a `CTestTestfile.cmake` — so the runner is foun
ctest prints `No tests were found!!!`, and trusting the exit status would report
a green suite for a project with no tests at all.

Projects that declare `type: test` targets in `build.yaml` are built and run
directly (no ctest/cargo/meson). On Windows those binaries are named
`<target>.exe`, matching the Ninja edge; `ebuild test` now asks ninja for that
path rather than the unsuffixed name.

## Test

```bash
Expand Down
39 changes: 38 additions & 1 deletion TASKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,44 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`.

| ID | Task | Owner | Mode | Status | Depends on |
|----|------|-------|------|--------|------------|
| T-002 | Fix Windows Ninja test-target path parsing | backend | Maintenance | todo | 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 |
| T-005 | Move `executable_output_path()` out of the Ninja-specific backend into a backend-neutral module (`ebuild/build/layout.py`), re-exported from `ninja_backend` for compatibility | backend | Maintenance | todo | none |

### Evidence (self-reported by implementer; pending independent review per `.ai/reviewer.md` — "if you implemented it, you do not approve it")

- **T-002**: `ebuild/cli/commands.py:2624` and `:2633` (the Ninja target-list
argv and the binary that gets executed in `_run_native_tests`) both use
`executable_output_path()` instead of rebuilding the path themselves.
Covered by
`tests/unit/test_golden_path_commands.py::TestTestTargetType::test_native_runner_asks_ninja_for_the_linked_binary`,
which forces `_exe_suffix()` to `.exe`; confirmed to fail against the
pre-fix argv construction and pass against the fix.
- **T-003**: Was deferred out of T-002 for reviewability, then folded back in
once `executable_output_path()` existed: `ebuild/cli/commands.py` now calls
it at the `package` artifact lookup instead of `Path(build_dir) / name`.
Covered by
`tests/unit/test_package_efw.py::TestCommandPacks::test_it_finds_the_windows_suffixed_artifact`,
which forces `_exe_suffix()` to `.exe` so it exercises the Windows path on
any host, and also caught the fix's ripple effect on the suite's own
real-Windows host: existing `test_package_efw.py` fixtures wrote an
unsuffixed stand-in binary, which the fixed lookup could no longer find
natively (`_exe_suffix()` returns `.exe` there unforced), so those
fixtures now build the artifact through `executable_output_path()` too.
- **T-004**: Third of three `build_dir / name` call sites, and the only one
with no diagnostic on the early-return path. `ebuild/cli/commands.py:516`
now uses `executable_output_path()`, and the bare `return` on a missing
artifact now logs at debug level, matching the function's other two early
exits. Covered by
`tests/unit/test_footprint.py::TestCLIFootprintReport::test_looks_up_the_windows_suffixed_artifact`,
which forces `_exe_suffix()` to `.exe` and chdirs into `tmp_path` so the
process cwd's own `eos.yaml`/`board.yaml`, if any, cannot change what it
measures; confirmed to fail against the pre-fix lookup (no report
emitted) and pass against the fix.
- **Suite result** (single run, both changes present, this Windows host):
**560 passed, 6 skipped, exit code 0**. Supersedes any other count quoted
for T-003 or T-004 elsewhere in this repo or in PR #110's description.

## Completed

Expand Down
27 changes: 26 additions & 1 deletion ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ def _exe_suffix() -> str:
return ".exe" if sys.platform == "win32" else ""


def executable_output_path(build_dir: Path, target_name: str) -> Path:
"""Return the linked binary path NinjaBackend emits for *target_name*.

Args:
build_dir: Directory that contains ``build.ninja`` and the linked
outputs.
target_name: The ``name`` of an ``executable`` or ``test`` target.

Returns:
``build_dir / target_name`` on POSIX, or that path with ``.exe``
appended on Windows -- the same path the Ninja edge in
``_write_ninja`` already names via ``_exe_suffix()``. A consumer
that rebuilds this path independently instead of calling this
function can silently drop the suffix and go looking for a binary
the edge never produced.

Example:
>>> from pathlib import Path
>>> executable_output_path(Path("_build"), "hello").name in (
... "hello", "hello.exe")
True
"""
return Path(build_dir) / (target_name + _exe_suffix())


def _shared_flag() -> str:
"""The flag that makes the compiler driver emit a shared object.

Expand Down Expand Up @@ -229,7 +254,7 @@ def _write_ninja(self) -> None:

link_inputs = obj_files + dep_archives
out = escape_ninja_path(
self.build_dir / (target.name + _exe_suffix()))
executable_output_path(self.build_dir, target.name))
lines.append(
f"build {out}: link " f"{' '.join(link_inputs)}"
)
Expand Down
20 changes: 14 additions & 6 deletions ebuild/cli/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@
import yaml

from ebuild import __version__
from ebuild.build.ninja_backend import NinjaBackend, PackagePaths
from ebuild.build.ninja_backend import (
NinjaBackend,
PackagePaths,
executable_output_path,
)
from ebuild.build.toolchain import resolve_toolchain
from ebuild.cli.integration import register_commands as _register_integration_commands
from ebuild.cli.logger import Logger
Expand Down Expand Up @@ -494,8 +498,9 @@ def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> No
if not binaries:
return

artifact = build_path / binaries[0].name
artifact = executable_output_path(build_path, binaries[0].name)
if not artifact.is_file():
log.debug(f"no artifact at {artifact}; skipping footprint")
return

prefix = getattr(cfg.toolchain, "target", None) or "host"
Expand Down Expand Up @@ -2625,7 +2630,7 @@ def package(log: Logger, config_path: str, build_dir: str,
log.error("No executable target in build.yaml — nothing to package.")
raise SystemExit(1)

artifact = Path(build_dir) / binaries[0].name
artifact = executable_output_path(Path(build_dir), binaries[0].name)
if not artifact.is_file():
log.error(f"No built artifact at {artifact}. Run 'ebuild build' first.")
raise SystemExit(1)
Expand Down Expand Up @@ -2708,11 +2713,14 @@ def _run_native_tests(
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.
# it with -f from the project root, so the same form is used here. The
# path must include the platform suffix: on Windows the edge is
# ``<name>.exe``, and asking ninja to build ``<name>`` is an unknown
# target.
argv = (
ninja_command()
+ ["-f", str(build_path / "build.ninja")]
+ [str(build_path / t.name) for t in selected]
+ [str(executable_output_path(build_path, t.name)) for t in selected]
)
result = subprocess.run(argv)
if result.returncode != 0:
Expand All @@ -2721,7 +2729,7 @@ def _run_native_tests(

failures: List[str] = []
for target in selected:
binary = build_path / target.name
binary = executable_output_path(build_path, target.name)
if not binary.is_file():
log.error(f"{target.name}: built, but no binary at {binary}")
failures.append(target.name)
Expand Down
26 changes: 25 additions & 1 deletion tests/ebuild/test_build_dir_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from click.testing import CliRunner

from ebuild.cli import commands
from tests.support import gcc_is_missing


pytestmark = pytest.mark.needs_yaml
Expand Down Expand Up @@ -243,9 +244,32 @@ def test_configure_and_build_from_outside_agree_on_the_build_dir(

# ── end to end, with a real compiler ────────────────────────

# `gcc_is_missing()` lives in tests/support.py, shared with
# tests/unit/test_footprint.py -- both need to know whether the host can
# link a real binary before running a skipif against it.


def test_gcc_probe_does_not_raise_when_gcc_cannot_start(monkeypatch):
"""Collection must stay a skip, not an ERROR, if gcc is absent."""
monkeypatch.setattr(
shutil, "which", lambda name: r"C:\missing\gcc.exe"
)

def boom(*args, **kwargs):
raise FileNotFoundError(2, "The system cannot find the file specified")

monkeypatch.setattr(subprocess, "run", boom)
assert gcc_is_missing() is True


def test_gcc_probe_reports_missing_when_which_finds_nothing(monkeypatch):
"""The common case on a bare Windows host: no gcc on PATH at all."""
monkeypatch.setattr(shutil, "which", lambda name: None)
assert gcc_is_missing() is True


@pytest.mark.skipif(
shutil.which("gcc") is None,
gcc_is_missing(),
reason="needs a working gcc to link the executable",
)
def test_end_to_end_build_from_outside_produces_the_binary(tmp_path, monkeypatch):
Expand Down
28 changes: 28 additions & 0 deletions tests/support.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Shared pytest helpers for the ebuild test suite."""

import shutil
import subprocess


def gcc_is_missing() -> bool:
"""True when this host cannot run gcc.

``subprocess.run(['gcc', ...])`` raises ``FileNotFoundError`` on Windows
when gcc is not installed. Evaluating that directly in ``skipif`` is not
a skip: it aborts collection of the file and, with default pytest, the
suite. Used by both ``tests/ebuild/test_build_dir_resolution.py`` and
``tests/unit/test_footprint.py`` -- previously each carried its own copy
of this probe, one of them the version this existed to fix.
"""
gcc = shutil.which("gcc")
if gcc is None:
return True
try:
return subprocess.run(
[gcc, "--version"], capture_output=True
).returncode != 0
except OSError:
return True
61 changes: 58 additions & 3 deletions tests/unit/test_footprint.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@
measure,
over_budget,
)
from tests.support import gcc_is_missing


class TestAccounting:
Expand All @@ -58,10 +59,9 @@ def test_a_pure_bss_buffer_costs_ram_but_not_flash(self):

class TestMeasure:
@pytest.mark.skipif(
shutil.which("gcc") is None or find_size_tool() is None,
reason="needs gcc and a size tool",
gcc_is_missing(),
reason="needs a working gcc to link the executable",
)

def test_measures_a_real_binary(self, tmp_path):
if shutil.which("gcc") is None:
pytest.skip("needs a working gcc to compile the test binary")
Expand Down Expand Up @@ -211,6 +211,61 @@ def test_units(self, n, expected):
assert format_size(n) == expected


class TestCLIFootprintReport:
"""`_report_footprint` (`ebuild/cli/commands.py`) is the CLI-level
consumer wired into `ebuild build`. Like `ebuild test` and `ebuild
package`, it has to look up the same suffixed binary NinjaBackend
linked."""

def test_looks_up_the_windows_suffixed_artifact(self, tmp_path, monkeypatch):
"""On Windows the linked binary is `<name>.exe`; the report must
look there, not at the unsuffixed name NinjaBackend never produces
on that platform -- the third of three call sites this rule applies
to, and the only one that failed silently.

`_exe_suffix()` is forced to `.exe` rather than switching on
`sys.platform`, so this exercises the Windows path -- and fails
against the pre-fix code -- on any host the suite runs on.

`_report_footprint` also reads `eos.yaml`/`board.yaml` relative to
the process cwd (via `_selected_board`/`_board_config`), which this
test does not exercise -- chdir into `tmp_path` so a real cwd
carrying either file cannot change what this test measures.
"""
from types import SimpleNamespace

from ebuild.build import ninja_backend
from ebuild.cli import commands
from ebuild.core.config import ProjectConfig, TargetConfig

monkeypatch.chdir(tmp_path)
monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe")
monkeypatch.setattr(
"ebuild.build.footprint.find_size_tool",
lambda prefix: "/usr/bin/size")
monkeypatch.setattr(
"ebuild.build.footprint.measure",
lambda artifact, tool=None: Footprint(text=1000, data=200, bss=500))

cfg = ProjectConfig(
name="app", version="1", source_dir=tmp_path,
targets=[TargetConfig(name="app", target_type="executable",
sources=["a.c"])],
)
build = tmp_path / "b"
build.mkdir()
(build / "app.exe").write_bytes(b"\x7fELF")

logs = []
log = SimpleNamespace(
info=logs.append, debug=lambda *a, **k: None,
warning=lambda *a, **k: None)

commands._report_footprint(cfg, build, log)

assert any("Flash" in line for line in logs), logs


def _completed(returncode=0, stdout="", stderr=""):
return subprocess.CompletedProcess(
args=["size"], returncode=returncode, stdout=stdout, stderr=stderr)
Expand Down
Loading