From 1bb4abdf23830bc06c82e0b9bd7c617939c9d7ee Mon Sep 17 00:00:00 2001 From: Vivek Darji Date: Mon, 14 Sep 2026 08:28:30 -0500 Subject: [PATCH 1/3] refactor: move executable output layout out of ninja backend --- TASKS.md | 10 ++++++- ebuild/build/layout.py | 33 ++++++++++++++++++++++ ebuild/build/ninja_backend.py | 37 ++----------------------- tests/unit/test_footprint.py | 4 +-- tests/unit/test_golden_path_commands.py | 4 +-- tests/unit/test_ninja_backend.py | 8 ++++++ tests/unit/test_package_efw.py | 6 ++-- 7 files changed, 59 insertions(+), 43 deletions(-) create mode 100644 ebuild/build/layout.py diff --git a/TASKS.md b/TASKS.md index 09f6bcb..c378e91 100644 --- a/TASKS.md +++ b/TASKS.md @@ -14,7 +14,7 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. | 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 | +| 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 | review | none | ### Evidence (self-reported by implementer; pending independent review per `.ai/reviewer.md` — "if you implemented it, you do not approve it") @@ -46,6 +46,14 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. 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. +- **T-005**: `executable_output_path()` now lives in + `ebuild/build/layout.py`; `ninja_backend` re-exports the same function for + compatibility, and consumers/tests use the backend-neutral owner. The + focused suite (`tests/unit/test_package_efw.py`, + `tests/unit/test_golden_path_commands.py`, `tests/unit/test_footprint.py`, + and `tests/unit/test_ninja_backend.py`) reports **119 passed, 1 skipped**; + the compatibility test asserts both import paths are identical and the + Windows suffix path is exercised. - **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. diff --git a/ebuild/build/layout.py b/ebuild/build/layout.py new file mode 100644 index 0000000..e63ec44 --- /dev/null +++ b/ebuild/build/layout.py @@ -0,0 +1,33 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Backend-neutral paths for generated build outputs.""" + +from __future__ import annotations + +import sys +from pathlib import Path + + +def _exe_suffix() -> str: + """Return the executable suffix used by the host platform.""" + return ".exe" if sys.platform == "win32" else "" + + +def executable_output_path(build_dir: Path, target_name: str) -> Path: + """Return the linked binary path for an executable or test target. + + Args: + build_dir: Directory containing the generated build files and outputs. + target_name: Name of the executable or test target. + + Returns: + The target path, including ``.exe`` on Windows. + + 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()) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index de417bd..ef27380 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -14,6 +14,8 @@ from pathlib import Path from typing import Dict, List, Optional +from ebuild.build.layout import executable_output_path + @dataclass class PackagePaths: @@ -30,41 +32,6 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} -def _exe_suffix() -> str: - """The extension the compiler driver gives an executable. - - gcc on Windows appends .exe when -o names no extension, so an edge - declaring "app" produced "app.exe" on disk: ninja never saw its own - output, treated the target as dirty and relinked on every build. - """ - 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. diff --git a/tests/unit/test_footprint.py b/tests/unit/test_footprint.py index c926816..f542f6d 100644 --- a/tests/unit/test_footprint.py +++ b/tests/unit/test_footprint.py @@ -245,12 +245,12 @@ def test_looks_up_the_windows_suffixed_artifact(self, tmp_path, monkeypatch): """ from types import SimpleNamespace - from ebuild.build import ninja_backend + from ebuild.build import layout 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(layout, "_exe_suffix", lambda: ".exe") monkeypatch.setattr( "ebuild.build.footprint.find_size_tool", lambda prefix: "/usr/bin/size") diff --git a/tests/unit/test_golden_path_commands.py b/tests/unit/test_golden_path_commands.py index 6231196..951ac26 100644 --- a/tests/unit/test_golden_path_commands.py +++ b/tests/unit/test_golden_path_commands.py @@ -182,12 +182,12 @@ def test_native_runner_asks_ninja_for_the_linked_binary(self, tmp_path, monkeypa """ from types import SimpleNamespace - from ebuild.build import ninja_backend + from ebuild.build import layout from ebuild.build.ninja_backend import NinjaBackend from ebuild.cli import commands from ebuild.core.config import ProjectConfig - monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe") + monkeypatch.setattr(layout, "_exe_suffix", lambda: ".exe") cfg = ProjectConfig( name="p", version="1", source_dir=tmp_path, diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index c0dd009..3db661d 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -13,6 +13,7 @@ import pytest +from ebuild.build import layout from ebuild.build.ninja_backend import NinjaBackend, escape_ninja_path from ebuild.build.toolchain import ResolvedToolchain from ebuild.core.config import ProjectConfig, TargetConfig @@ -22,6 +23,13 @@ def _toolchain(): return SimpleNamespace(cc="cc", cxx="c++", ar="ar") +def test_executable_path_is_reexported_for_backend_compatibility(): + """Existing Ninja imports must resolve to the neutral layout helper.""" + from ebuild.build.ninja_backend import executable_output_path as legacy_path + + assert legacy_path is layout.executable_output_path + + 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 used the diff --git a/tests/unit/test_package_efw.py b/tests/unit/test_package_efw.py index c2eb8ee..2beed72 100644 --- a/tests/unit/test_package_efw.py +++ b/tests/unit/test_package_efw.py @@ -30,7 +30,7 @@ missing_tool_message, pack, ) -from ebuild.build.ninja_backend import executable_output_path +from ebuild.build.layout import executable_output_path from ebuild.cli.commands import cli @@ -273,8 +273,8 @@ def test_it_finds_the_windows_suffixed_artifact(self, tmp_path, monkeypatch): against the pre-fix code, which looked for the unsuffixed name -- on any host the suite runs on. """ - from ebuild.build import ninja_backend - monkeypatch.setattr(ninja_backend, "_exe_suffix", lambda: ".exe") + from ebuild.build import layout + monkeypatch.setattr(layout, "_exe_suffix", lambda: ".exe") tool = _efwtool_that_packs(tmp_path) monkeypatch.chdir(self._project(tmp_path)) From 0558e0c3a2d9282270b236ecb7b9790f7891ed84 Mon Sep 17 00:00:00 2001 From: Vivek Darji Date: Mon, 14 Sep 2026 13:02:39 -0500 Subject: [PATCH 2/3] refactor: route CLI output paths through neutral layout --- TASKS.md | 12 ++++++++---- ebuild/build/layout.py | 11 ++++++++++- ebuild/cli/commands.py | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/TASKS.md b/TASKS.md index c378e91..3173bda 100644 --- a/TASKS.md +++ b/TASKS.md @@ -48,15 +48,19 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. emitted) and pass against the fix. - **T-005**: `executable_output_path()` now lives in `ebuild/build/layout.py`; `ninja_backend` re-exports the same function for - compatibility, and consumers/tests use the backend-neutral owner. The + compatibility, and CLI consumers/tests use the backend-neutral owner. The focused suite (`tests/unit/test_package_efw.py`, `tests/unit/test_golden_path_commands.py`, `tests/unit/test_footprint.py`, and `tests/unit/test_ninja_backend.py`) reports **119 passed, 1 skipped**; the compatibility test asserts both import paths are identical and the Windows suffix path is exercised. -- **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. +- **Suite result**: the earlier **560 passed, 6 skipped, exit code 0** record + predates the current branch and must not be used as its validation result. + On the current PR head, the full suite reports **670 passed, 4 skipped, 10 + failed**: nine `tests/unit/test_index_sync.py` cases fail because + `PackageRecipe.to_dict` is missing, and + `tests/ebuild/test_build_dir_resolution.py::test_end_to_end_build_from_outside_produces_the_binary` + also fails. These failures reproduce on `master` and are unrelated to T-005. ## Completed diff --git a/ebuild/build/layout.py b/ebuild/build/layout.py index e63ec44..68a780d 100644 --- a/ebuild/build/layout.py +++ b/ebuild/build/layout.py @@ -10,13 +10,22 @@ def _exe_suffix() -> str: - """Return the executable suffix used by the host platform.""" + """Return the executable suffix used by the host platform. + + Compiler drivers on Windows append ``.exe`` when ``-o`` names no + extension. Keeping that platform detail here prevents consumers from + rebuilding an output path that does not name the binary on disk. + """ return ".exe" if sys.platform == "win32" else "" def executable_output_path(build_dir: Path, target_name: str) -> Path: """Return the linked binary path for an executable or test target. + Consumers must use this helper rather than rebuilding ``build_dir / + target_name`` independently: on Windows, dropping the compiler-added + suffix makes the consumer look for a binary the build never produced. + Args: build_dir: Directory containing the generated build files and outputs. target_name: Name of the executable or test target. diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index de8928c..47cf213 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -26,10 +26,10 @@ import yaml from ebuild import __version__ +from ebuild.build.layout import executable_output_path 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 8392522ee23593c6a84e253c0ebc643a8ce833ac Mon Sep 17 00:00:00 2001 From: Vivek Darji Date: Mon, 14 Sep 2026 13:05:35 -0500 Subject: [PATCH 3/3] docs: reconcile executable layout validation evidence --- TASKS.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/TASKS.md b/TASKS.md index 3173bda..c4291b5 100644 --- a/TASKS.md +++ b/TASKS.md @@ -56,11 +56,13 @@ Status is one of: `todo`, `in-progress`, `blocked`, `review`, `done`. Windows suffix path is exercised. - **Suite result**: the earlier **560 passed, 6 skipped, exit code 0** record predates the current branch and must not be used as its validation result. - On the current PR head, the full suite reports **670 passed, 4 skipped, 10 - failed**: nine `tests/unit/test_index_sync.py` cases fail because - `PackageRecipe.to_dict` is missing, and - `tests/ebuild/test_build_dir_resolution.py::test_end_to_end_build_from_outside_produces_the_binary` - also fails. These failures reproduce on `master` and are unrelated to T-005. + On the current PR head, the full suite on Windows CPython 3.14 reports + **669 passed, 6 skipped, 9 failed**; all nine failures are in + `tests/unit/test_index_sync.py` because `PackageRecipe.to_dict` is missing. + The independent Linux review run selected a different platform-sensitive + set (**670 passed, 4 skipped, 10 failed**), including one build-directory + test. These failures reproduce outside T-005 and are not hidden by this + change. ## Completed