diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c6c26..c665a6c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,15 @@ on: pull_request: branches: [master, main] +# Every other workflow in this repo declares a concurrency group; ci.yml, +# the heaviest one, did not. Pushing twice to a PR left the earlier run +# queued, and both competed for the same scarce windows/macos runners -- +# three superseded runs sat ahead of the current one for over an hour. +# cancel-in-progress because a superseded commit's result is not wanted. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: # ── Test Matrix ─────────────────────────────────────────────────────────── test: @@ -17,7 +26,12 @@ jobs: strategy: matrix: python-version: ["3.10", "3.11", "3.12"] - os: [ubuntu-22.04, macos-13, windows-2022] + # macos-latest, not macos-13: the macos-13 image is retired, so jobs + # requesting it queue forever and never get a runner. Across four runs + # on this branch the ubuntu-22.04 and windows-2022 jobs all started and + # finished while the three macos-13 jobs sat queued for over two hours. + # Every other workflow in this repo already uses macos-latest. + os: [ubuntu-22.04, macos-latest, windows-2022] fail-fast: false steps: @@ -42,8 +56,12 @@ jobs: run: ruff check . --select=E,F,W --ignore=E501 continue-on-error: true + # --exclude: layers/eosuite/ vendors its own tests/ package, so a bare + # `mypy .` sees two modules named "tests" and bails with "Duplicate + # module named 'tests'" before checking anything. continue-on-error hid + # that the type check was doing no work at all. - name: Type check (mypy) - run: mypy . --ignore-missing-imports --no-strict-optional + run: mypy . --ignore-missing-imports --no-strict-optional --exclude '^layers/' continue-on-error: true # Runs the whole tests/ tree. The previous steps ran only tests/unit/ @@ -57,7 +75,13 @@ jobs: # coverage policy already lives in codecov.yml; whether to also # enforce it here is a maintainer decision, so this change leaves # both of those numbers alone. + # + # shell: bash — the matrix includes windows-2022, where the default + # shell is PowerShell and the backslash line continuations below are a + # syntax error ("Missing expression after unary operator '--'"), so the + # Windows leg of this job failed before pytest ever started. - name: Run test suite + shell: bash run: | python -m pytest tests/ -v --tb=short \ --cov=ebuild --cov-report=xml --cov-report=term-missing \ diff --git a/.github/workflows/vendor-drift.yml b/.github/workflows/vendor-drift.yml new file mode 100644 index 0000000..145ca5e --- /dev/null +++ b/.github/workflows/vendor-drift.yml @@ -0,0 +1,57 @@ +name: Vendored core drift + +# core/eos/ and core/eboot/ are snapshots of other repositories in this +# organisation. Fixes merged upstream never reach them, so they drift silently. +# This job makes that drift visible and fails when it grows. +# +# See core/UPSTREAM.yaml for the pins, and ADR-019 in the eos repository for the +# decision to replace the snapshots with real dependencies. + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + paths: + - 'core/**' + - 'scripts/check_vendor_drift.py' + - '.github/workflows/vendor-drift.yml' + schedule: + # Weekly, so upstream moving on its own is noticed even when nothing here changes. + - cron: '0 5 * * 1' + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + drift: + name: Compare core/ against pinned upstreams + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + + - name: Check vendored snapshots + run: python3 scripts/check_vendor_drift.py --list + + - name: Explain a failure + if: failure() + run: | + echo "::notice::A file under core/eos or core/eboot now differs from its" + echo "::notice::pinned upstream in a way it did not before." + echo "::notice::" + echo "::notice::Fix it in one of these two ways:" + echo "::notice:: 1. The change belongs upstream — open a PR against" + echo "::notice:: embeddedos-org/eos or embeddedos-org/eBoot, merge it, then" + echo "::notice:: bump the revision in core/UPSTREAM.yaml." + echo "::notice:: 2. The change was unintended — revert it here." + echo "::notice::" + echo "::notice::Raising baseline_drift is not one of the two ways." diff --git a/.gitignore b/.gitignore index 64115a9..549c42d 100644 --- a/.gitignore +++ b/.gitignore @@ -5,7 +5,9 @@ __pycache__/ *.egg-info/ *.egg dist/ -build/ +# Anchored: the bare pattern also matched ebuild/build/, the source package, +# so any new module added there was silently untracked. +/build/ .eggs/ *.whl @@ -64,3 +66,4 @@ Desktop.ini build-*/ node_modules/ target/ +_build/ diff --git a/core/UPSTREAM.yaml b/core/UPSTREAM.yaml new file mode 100644 index 0000000..04f1349 --- /dev/null +++ b/core/UPSTREAM.yaml @@ -0,0 +1,24 @@ +# Upstream pins for the vendored snapshots under core/. +# +# core/eos/ and core/eboot/ are copies of other repositories in this +# organisation, not original source. ADR-019 in the eos repository records the +# decision to replace them with real pinned dependencies. Until that lands, this +# file is the pin and scripts/check_vendor_drift.py enforces it. +# +# baseline_drift is the number of files that already differed from the pinned +# revision when the guard was introduced. It is grandfathered so the guard could +# be merged without blocking work already in flight, and it must only ever go +# down. Reconciling a file means sending the change upstream or reverting it +# here — see ADR-019 decision item 3. Raising this number to make CI pass +# defeats the entire point of the file. + +vendored: + - path: core/eos + repository: https://github.com/embeddedos-org/eos.git + revision: 5544c98df94ff460c3a074fb4280c614b784b464 + baseline_drift: 44 + + - path: core/eboot + repository: https://github.com/embeddedos-org/eBoot.git + revision: 39b09253450b3789b66f64ff0465b03b7910295d + baseline_drift: 46 diff --git a/core/eos/core/src/graph.c b/core/eos/core/src/graph.c index 72f94aa..5fac4d0 100644 --- a/core/eos/core/src/graph.c +++ b/core/eos/core/src/graph.c @@ -21,6 +21,7 @@ EosResult eos_graph_add_node(EosGraph *g, const char *name, EosNodeType type, int id = g->node_count; EosNode *n = &g->nodes[id]; strncpy(n->name, name, EOS_MAX_NAME - 1); + n->name[EOS_MAX_NAME-1] = '\0'; n->type = type; n->build_type = build_type; n->status = EOS_NODE_PENDING; diff --git a/core/eos/tests/test_graph.c b/core/eos/tests/test_graph.c index 40a16bc..a93afc9 100644 --- a/core/eos/tests/test_graph.c +++ b/core/eos/tests/test_graph.c @@ -52,7 +52,46 @@ static void test_graph_add_nodes(void) { ASSERT(g.node_count == 3, "graph has 3 nodes"); } +static void test_graph_max_length_node_name(void) { + printf("test_graph_max_length_node_name:\n"); + EosGraph g; + eos_graph_init(&g); + + char name[EOS_MAX_NAME]; + + /* Create a name that fills the buffer except for the null terminator. */ + memset(name, 'A', EOS_MAX_NAME - 1); + name[EOS_MAX_NAME - 1] = '\0'; + + /* + * Fill the destination with non-zero data so the test can detect + * a missing null terminator even though eos_graph_init() zeroes + * the graph initially. + */ + memset(g.nodes[0].name, 'X', EOS_MAX_NAME); + + int id; + EosResult r = eos_graph_add_node( + &g, + name, + EOS_NODE_PACKAGE, + EOS_BUILD_CMAKE, + &id + ); + + ASSERT(r == EOS_OK, "maximum-length node name can be added"); + + ASSERT( + g.nodes[id].name[EOS_MAX_NAME - 1] == '\0', + "node name is null terminated" + ); + + ASSERT( + eos_graph_find_node(&g, name) == id, + "maximum-length node name can be found" + ); +} static void test_graph_find_node(void) { printf("test_graph_find_node:\n"); EosGraph g; @@ -168,6 +207,7 @@ int main(void) { test_graph_init(); test_graph_add_nodes(); test_graph_find_node(); + test_graph_max_length_node_name(); test_topological_sort_linear(); test_topological_sort_diamond(); test_cycle_detection(); diff --git a/docs/book/book.md b/docs/book/book.md index e03618c..4f4251f 100644 --- a/docs/book/book.md +++ b/docs/book/book.md @@ -935,6 +935,23 @@ Request: freertos >= 10.5, mbedtls ^3.0 +-------------------+ ``` +#### Version ordering + +A request without a version resolves to the highest version of that package in +the registry. `version` is a free-form string — the recipe format does not +require dotted integers — so the ordering is defined as follows: + +| Rule | Example | +|---|---| +| A leading `v` or `V` is ignored | `v2.9.3` ranks with `2.9.3` | +| All-digit components compare numerically | `1.10.0` > `1.9.0` | +| Any other component compares as text, below any numeric one | `1.x` < `1.0` | +| A `-` or `+` suffix ranks below the same version without one | `3.6.0-rc1` < `3.6.0` | + +Every version string has a place in this order, including ones that carry no +numbers at all (`main`), so one unusual recipe cannot break lookup for the +packages around it. + --- ## Chapter 13: SDK Generation diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708df..9e5bdaa 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -25,6 +25,30 @@ ALL_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild", "ninja"} +#: Backends this dispatcher actually drives. "ninja" is ebuild's own backend -- +#: the CLI invokes NinjaBackend directly and never routes it through here. +DISPATCHED_BACKENDS = {"cmake", "make", "meson", "cargo", "kbuild"} + + +class UnknownBackendError(ValueError, RuntimeError): + """Raised when a backend reaches the dispatcher that it cannot drive. + + Subclasses both ValueError and RuntimeError: callers treat an unrecognized + backend name as a bad argument, while the CLI treats a backend it failed to + route (notably "ninja") as a routing failure. Silently doing nothing here is + what made `ebuild build` report "Build completed successfully" without ever + running a compiler. + """ + + +def _unknown_backend(backend: str, step: str) -> UnknownBackendError: + return UnknownBackendError( + f"Unknown build backend '{backend}'. " + f"Supported backends: {', '.join(sorted(DISPATCHED_BACKENDS))}. " + "ebuild's own 'ninja' backend is invoked directly by the CLI and is " + f"not dispatched here, so it cannot be {step} through BackendDispatcher." + ) + def detect_backend(source_dir: Path) -> str: """Auto-detect the build system from project files. @@ -100,7 +124,7 @@ def configure( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} self.build_dir.mkdir(parents=True, exist_ok=True) @@ -121,23 +145,11 @@ 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." - ) + raise _unknown_backend(backend, "configured") def build( self, @@ -154,7 +166,7 @@ def build( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ config = config or {} @@ -185,10 +197,7 @@ def build( _run_or_log(cmd, dry_run) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) + raise _unknown_backend(backend, "built") def clean( self, @@ -203,7 +212,7 @@ def clean( dry_run: If True, log commands instead of executing them. Raises: - ValueError: If the backend is not recognized. + UnknownBackendError: If the backend is not one this dispatcher drives. """ if backend == "cmake": _run_or_log( @@ -237,7 +246,4 @@ def clean( check=False, ) else: - raise ValueError( - f"Unknown build backend '{backend}'. " - f"Supported backends: {', '.join(sorted(ALL_BACKENDS))}" - ) + raise _unknown_backend(backend, "cleaned") diff --git a/ebuild/build/firmware_image.py b/ebuild/build/firmware_image.py new file mode 100644 index 0000000..79aa012 --- /dev/null +++ b/ebuild/build/firmware_image.py @@ -0,0 +1,149 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Assemble an eFirmware `.efw` image from a built artifact. + +§29's development-to-device flow ends: + + eBuild -> {EoS, eBoot, application} -> eFirmware artifact -> {EoSim, hardware} + +Every piece of that existed except the arrow into eFirmware. The +`embeddedos-org/eFirmware` repository implements the image format and ships +`efwtool` to pack, inspect and verify one; nothing in ebuild referenced it, so +a developer had to know the tool existed, build it themselves, and run it by +hand. + +This drives `efwtool` rather than re-implementing the header in Python. The +header is a packed C struct with a magic, a version, a size and a SHA-256, and +a second implementation of it in another language is a second thing to keep in +step — the exact failure this repository has been repairing all week. If the +format changes, the tool changes with it and this keeps working. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +from pathlib import Path +from typing import List, Optional + +#: Where `ebuild setup` caches the sibling repositories. +_EFW_REPO = "efirmware" + +#: Built once and reused, inside the clone, so `ebuild setup` stays the only +#: thing that owns ~/.ebuild/repos. +_BUILD_DIRNAME = "_ebuild" + + +class FirmwareImageError(RuntimeError): + """Raised when an image cannot be assembled.""" + + +def find_efwtool(repos_dir: Path) -> Optional[Path]: + """Locate `efwtool`, building the cached eFirmware checkout if needed. + + Returns None when the checkout is absent or cannot be built, so the caller + can say what to run rather than failing with a stack trace. + """ + on_path = shutil.which("efwtool") + if on_path: + return Path(on_path) + + root = Path(repos_dir) / _EFW_REPO + if not (root / "CMakeLists.txt").is_file(): + return None + + build_dir = root / _BUILD_DIRNAME + existing = _first_efwtool(build_dir) + if existing: + return existing + + if shutil.which("cmake") is None: + return None + 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 None + return _first_efwtool(build_dir) + + +def _first_efwtool(build_dir: Path) -> Optional[Path]: + if not build_dir.is_dir(): + return None + for candidate in sorted(build_dir.rglob("efwtool")): + if candidate.is_file() and os.access(candidate, os.X_OK): + return candidate + return None + + +def pack(efwtool: Path, payload: Path, output: Path, *, + version: str = "0.0.0", + load_addr: Optional[str] = None, + entry_addr: Optional[str] = None) -> None: + """Wrap ``payload`` in an eFirmware header, writing ``output``.""" + if not payload.is_file(): + raise FirmwareImageError(f"no artifact to package at {payload}") + + argv: List[str] = [str(efwtool), "pack", str(payload), str(output), + "--version", version] + if load_addr: + argv += ["--load", load_addr] + if entry_addr: + argv += ["--entry", entry_addr] + + proc = _run(argv, f"packing {payload.name}") + if not output.is_file(): + raise FirmwareImageError( + f"efwtool reported success but wrote no image at {output}: " + f"{proc.stdout.strip()[:200]}" + ) + + +def verify(efwtool: Path, image: Path) -> str: + """Re-read the image through efwtool. Returns its verdict line. + + Packing and verifying with the same tool does not prove the format is + right, but it does prove the file on disk parses — which catches a + truncated write, a wrong path, and a payload that never got copied. + """ + return _run([str(efwtool), "verify", str(image)], + f"verifying {image.name}").stdout.strip() + + +def inspect(efwtool: Path, image: Path) -> str: + return _run([str(efwtool), "inspect", str(image)], + f"inspecting {image.name}").stdout.strip() + + +def _run(argv: List[str], what: str) -> subprocess.CompletedProcess: + try: + proc = subprocess.run(argv, capture_output=True, text=True, timeout=300) + except (OSError, subprocess.TimeoutExpired) as exc: + raise FirmwareImageError(f"{what} failed: {exc}") from exc + if proc.returncode != 0: + raise FirmwareImageError( + f"{what} failed: {(proc.stderr or proc.stdout).strip()[:300]}" + ) + return proc + + +def missing_tool_message(repos_dir: Path) -> str: + """What to tell a developer who has no efwtool.""" + root = Path(repos_dir) / _EFW_REPO + if not root.is_dir(): + return ( + "eFirmware is not in the local cache, so no .efw image can be " + "assembled.\n" + " Run 'ebuild setup' to fetch it, or put efwtool on PATH." + ) + return ( + f"eFirmware is cached at {root} but efwtool could not be built.\n" + " Build it by hand with:\n" + f" cmake -S {root} -B {root / _BUILD_DIRNAME} && " + f"cmake --build {root / _BUILD_DIRNAME}" + ) diff --git a/ebuild/build/footprint.py b/ebuild/build/footprint.py new file mode 100644 index 0000000..0414f81 --- /dev/null +++ b/ebuild/build/footprint.py @@ -0,0 +1,228 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint of a built artifact. + +The MLP developer walk ends with a build that says how much of the board it +just used: + + Flash: 384 KB + RAM: 72 KB + Ready to flash. + +A developer who has to run `arm-none-eabi-size` themselves and remember which +columns to add is not being told; they are being left to find out. + +The accounting matches `scripts/measure_footprint.py` in the eos repo, so the +two tools cannot disagree about what a number means: + + text code + read-only data -> flash + data initialised writable data -> flash AND RAM + bss zero-initialised data -> RAM only + + flash = text + data + ram = data + bss + +`data` is charged to both because it is stored in flash and copied to RAM at +startup. Reading `size`'s "dec" column instead understates RAM. +""" + +from __future__ import annotations + +import re +import shutil +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, Optional, Tuple + +#: Capacity of the reference part for each board family, in bytes. +#: +#: These are the parts `ebuild new --board` scaffolds against. A family spans +#: several densities -- an STM32F407 has 1 MB of flash and an STM32F401 has +#: 256 KB -- so a project that cares states its own numbers under `memory:` in +#: its board YAML, which takes precedence over anything here. +#: +#: Boards that boot from removable or external storage, and Linux-class parts +#: with no fixed budget, are absent on purpose: a made-up ceiling is worse +#: than no ceiling, because a percentage reads as authoritative. +_REFERENCE_CAPACITY: Dict[str, Tuple[int, int]] = { + # board flash ram + "nrf52": (512 * 1024, 64 * 1024), # nRF52832 + "nrf52840": (1024 * 1024, 256 * 1024), # nRF52840 + "stm32f4": (1024 * 1024, 192 * 1024), # STM32F407 + "stm32h7": (2048 * 1024, 1024 * 1024), # STM32H743 + "rp2040": (2048 * 1024, 264 * 1024), # RP2040 + 2 MB QSPI + "esp32": (4096 * 1024, 520 * 1024), # ESP32-WROOM-32, 4 MB module + "tms570": (3072 * 1024, 256 * 1024), # TMS570LS3137 +} + +_SIZE_LINE = re.compile( + r"^\s*(?P\d+)\s+(?P\d+)\s+(?P\d+)\s+\d+\s+[0-9a-fA-F]+\s" +) + + +class FootprintError(RuntimeError): + """Raised when a footprint cannot be measured.""" + + +@dataclass(frozen=True) +class Footprint: + """Section sizes of one artifact, in bytes.""" + + text: int + data: int + bss: int + + @property + def flash(self) -> int: + """Bytes occupied in flash: code, read-only data, and the stored + image of initialised writable data.""" + return self.text + self.data + + @property + def ram(self) -> int: + """Bytes occupied in RAM once started: initialised data copied out of + flash, plus the zero-initialised region.""" + return self.data + self.bss + + +def find_size_tool(toolchain_prefix: Optional[str] = None) -> Optional[str]: + """Locate the `size` binary for a toolchain. + + A cross build must be measured with its own `size`; the host one reads an + ARM ELF's headers but is not guaranteed to across every binutils version, + and silently reporting host numbers for a firmware image is worse than + reporting none. Returns None when nothing suitable is installed. + """ + if toolchain_prefix and toolchain_prefix != "host": + cross = shutil.which(f"{toolchain_prefix}-size") + if cross: + return cross + # No fallback to the host tool: the numbers would be for a different + # target and nothing in the output would say so. + return None + return shutil.which("size") + + +def measure(artifact: Path, size_tool: Optional[str] = None) -> Footprint: + """Section sizes of ``artifact``. + + Raises FootprintError rather than returning zeros, so a build cannot + report "Flash: 0 KB" when the truth is that nothing was measured. + """ + artifact = Path(artifact) + if not artifact.is_file(): + raise FootprintError(f"no artifact to measure at {artifact}") + + tool = size_tool or shutil.which("size") + if not tool: + raise FootprintError( + "no 'size' tool on PATH, so the footprint cannot be measured " + "(install binutils, or the toolchain's binutils for a cross build)" + ) + + try: + proc = subprocess.run([tool, str(artifact)], capture_output=True, + text=True, timeout=60) + except (OSError, subprocess.TimeoutExpired) as exc: + raise FootprintError(f"{tool} failed on {artifact}: {exc}") from exc + + if proc.returncode != 0: + raise FootprintError( + f"{tool} exited {proc.returncode} on {artifact}: " + f"{(proc.stderr or proc.stdout).strip()[:200]}" + ) + + for line in proc.stdout.splitlines(): + m = _SIZE_LINE.match(line) + if m: + return Footprint(text=int(m.group("text")), + data=int(m.group("data")), + bss=int(m.group("bss"))) + + raise FootprintError( + f"could not read a size line from {tool} output for {artifact}" + ) + + +def board_capacity(board: Optional[str], + board_config: Optional[dict] = None + ) -> Tuple[Optional[int], Optional[int]]: + """Flash and RAM capacity for a board, or (None, None) when unknown. + + A project's own board YAML wins over the reference table: `memory.flash_size` + and `memory.ram_size` are what the hardware descriptions in `hardware/board/` + already use. + """ + if board_config: + memory = board_config.get("memory") or {} + flash = _as_bytes(memory.get("flash_size")) + ram = _as_bytes(memory.get("ram_size")) + if flash or ram: + return flash, ram + if board: + found = _REFERENCE_CAPACITY.get(board.lower()) + if found: + return found + return None, None + + +def _as_bytes(value) -> Optional[int]: + """Board YAMLs write sizes as ints or as hex strings like ``0x200000``.""" + if value is None: + return None + if isinstance(value, int): + return value if value > 0 else None + try: + parsed = int(str(value), 0) + except ValueError: + return None + return parsed if parsed > 0 else None + + +def format_size(n: int) -> str: + """Human-readable size, at the granularity a developer reasons in.""" + if n < 1024: + return f"{n} B" + if n < 1024 * 1024: + return f"{n / 1024:.1f} KB" + return f"{n / (1024 * 1024):.2f} MB" + + +def format_report(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> str: + """The build's closing footprint block. + + A percentage is shown only where the capacity is actually known. Printing + one against a guessed ceiling would be the most misleading number in the + whole build. + """ + lines = [] + for label, used, cap in (("Flash", fp.flash, flash_capacity), + ("RAM ", fp.ram, ram_capacity)): + if cap: + pct = 100.0 * used / cap + lines.append(f" {label}: {format_size(used):>10}" + f" of {format_size(cap):>10} ({pct:.1f}%)") + else: + lines.append(f" {label}: {format_size(used):>10}") + return "\n".join(lines) + + +def over_budget(fp: Footprint, + flash_capacity: Optional[int] = None, + ram_capacity: Optional[int] = None) -> Optional[str]: + """A message naming the region that does not fit, or None. + + Being told at the end of a build beats being told by a linker script, and + beats being told by a board that will not boot. + """ + if flash_capacity and fp.flash > flash_capacity: + return (f"flash usage {format_size(fp.flash)} exceeds the board's " + f"{format_size(flash_capacity)}") + if ram_capacity and fp.ram > ram_capacity: + return (f"RAM usage {format_size(fp.ram)} exceeds the board's " + f"{format_size(ram_capacity)}") + return None diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index 5aa2f37..d416c15 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -30,6 +30,25 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _ninja_path(path) -> str: + """Escape *path* for use in a Ninja build statement. + + Ninja splits build statements on unescaped spaces and colons, so a Windows + absolute path writes a drive letter that Ninja reads as the output/rule + separator: + + build C:\\...\\main.o: cc main.c + ^ "expected build command name" + + `$` is escaped first so the escapes introduced below are not re-escaped. + Only build statements need this; variable values (cflags, ldflags) are read + to end of line and must not be escaped, or the flags reach the compiler + mangled. + """ + text = str(path) + return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ") + + class NinjaBackend: """Generate build.ninja from a ProjectConfig and resolved toolchain. @@ -104,6 +123,22 @@ def _resolve_target_cflags(self, target) -> List[str]: return cflags + def _object_path(self, target, src: str) -> Path: + """Object file path for *src* as compiled by *target*. + + Object paths are namespaced by target name. Two targets may legitimately + list the same source: a library and a test binary sharing a helper, or + one source built twice with different defines. Each needs its own + object, because each compiles with its own cflags. Keying only on the + source made both targets claim one output, which ninja rejects with + "multiple rules generate ...". + + Example: + >>> backend._object_path(target, "src/main.c") # target.name == "app" + PosixPath('_build/obj/app/src/main.o') + """ + return (self.build_dir / "obj" / target.name / src).with_suffix(".o") + def _write_ninja(self) -> None: """Write the build.ninja file.""" ninja_path = self.build_dir / "build.ninja" @@ -123,10 +158,6 @@ def _write_ninja(self) -> None: " command = $cc $ldflags $in -o $out $libs", " description = LINK $out", "", - "rule link_shared", - " command = $cc -shared $ldflags $in -o $out $libs", - " description = LINK_SHARED $out", - "", "rule ar_rule", " command = $ar rcs $out $in", " description = AR $out", @@ -143,7 +174,7 @@ def _write_ninja(self) -> None: obj = str(self._object_path(target, src)) obj_files.append(obj) lines.append( - f"build {obj}: cc {src}" + f"build {_ninja_path(obj)}: cc {_ninja_path(src)}" ) if cflags: lines.append(f" cflags = {' '.join(cflags)}") @@ -170,7 +201,8 @@ def _write_ninja(self) -> None: link_inputs = obj_files + dep_archives out = str(self.build_dir / target.name) lines.append( - f"build {out}: link {' '.join(link_inputs)}" + f"build {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(i) for i in link_inputs)}" ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") @@ -188,7 +220,10 @@ def _write_ninja(self) -> None: out = str(self.build_dir / f"lib{target.name}{ext}") if target.target_type == "static_library": - lines.append(f"build {out}: ar_rule {' '.join(obj_files)}") + lines.append( + f"build {_ninja_path(out)}: ar_rule " + f"{' '.join(_ninja_path(o) for o in obj_files)}" + ) else: # Shared libraries need the platform's "build a shared # object" flag and the same -L/-l wiring executables get, @@ -204,7 +239,10 @@ 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 {_ninja_path(out)}: link " + f"{' '.join(_ninja_path(o) for o in 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 b7b9522..4366ac3 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1,208 +1,208 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2026 EoS Project - -"""CLI commands for ebuild using Click. - -Provides build, clean, configure, info, install, add, list-packages, -pipeline, and hardware analysis commands. -""" - -from __future__ import annotations - -import os -import shutil -import subprocess -import threading -import sys -from pathlib import Path -from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING - -if TYPE_CHECKING: - from ebuild.eos_ai.eos_hw_analyzer import HardwareProfile - -import click -import yaml - -from ebuild import __version__ -from ebuild.build.ninja_backend import NinjaBackend, PackagePaths -from ebuild.build.toolchain import resolve_toolchain -from ebuild.cli.logger import Logger -from ebuild.core.config import ConfigError, load_config, ProjectConfig -from ebuild.core.graph import CycleError, DependencyGraph, build_dependency_graph -from ebuild.core.scheduler import run_graph -from ebuild.packages.builder import BuildError, PackageBuilder -from ebuild.packages.cache import PackageCache -from ebuild.packages.fetcher import FetchError, PackageFetcher -from ebuild.packages.lockfile import Lockfile -from ebuild.packages.recipe import RecipeError -from ebuild.packages.registry import create_registry -from ebuild.packages.resolver import PackageResolver, ResolveError - - -pass_logger = click.make_pass_decorator(Logger, ensure=True) - -# Default recipe search paths (relative to project root) -_RECIPE_DIRS = ["recipes"] - - -def _find_recipe_dirs(project_dir: Path) -> List[Path]: - """Locate recipe directories: project-local and install-level.""" - dirs = [] - for name in _RECIPE_DIRS: - d = project_dir / name - if d.is_dir(): - dirs.append(d) - - # Also check ebuild install location - pkg_recipes = Path(__file__).resolve().parent.parent.parent / "recipes" - if pkg_recipes.is_dir() and pkg_recipes not in dirs: - dirs.append(pkg_recipes) - - return dirs - - -def _install_packages( - cfg: ProjectConfig, - build_dir: Path, - log: Logger, - verbose: bool = False, - jobs: int = 1, -) -> Dict[str, PackagePaths]: - """Resolve, fetch, build, and return PackagePaths for all declared packages. - - Args: - jobs: Maximum packages to build concurrently. 1 (the default) preserves - the sequential build order exactly. - - Returns a dict mapping package name to PackagePaths for use by NinjaBackend. - """ - if not cfg.packages: - return {} - - log.step("Resolving packages...") - - recipe_dirs = _find_recipe_dirs(cfg.source_dir) - if not recipe_dirs: - log.warning("No recipe directories found. Create a 'recipes/' directory.") - return {} - - registry = create_registry(*recipe_dirs) - log.debug(f"Registry: {registry.package_count} recipes from {[str(p) for p in registry.search_paths]}") - - resolver = PackageResolver(registry) - requested = [{"name": p.name, "version": p.version} for p in cfg.packages] - resolved = resolver.resolve(requested) - - log.info(f"Packages to install: {', '.join(r.name + ' v' + r.version for r in resolved)}") - - # Lockfile - lock_path = cfg.source_dir / Lockfile.FILENAME - lockfile = Lockfile(lock_path) - - # Cache and fetcher - pkg_cache_dir = build_dir / "packages" - cache = PackageCache(pkg_cache_dir) - fetcher = PackageFetcher(pkg_cache_dir / "_downloads") - - # Build each package, honouring dependency order. Independent packages run - # concurrently when jobs > 1. - builder = PackageBuilder(cache, verbose=verbose) - install_dirs: Dict[str, Path] = {} - by_name = {r.name: r for r in resolved} - - graph = DependencyGraph() - for recipe in resolved: - graph.add_node(recipe.name) - for recipe in resolved: - for dep in recipe.dependencies: - if dep in by_name: - graph.add_edge(recipe.name, dep) - - dirs_lock = threading.Lock() - log_lock = threading.Lock() - - def build_one(name: str) -> Path: - recipe = by_name[name] - - if cache.is_built(recipe): - with dirs_lock: - install_dirs[name] = cache.install_dir(recipe) - with log_lock: - log.info(f" {recipe.name} v{recipe.version} — cached ✓") - return install_dirs[name] - - with log_lock: - log.step(f" Fetching {recipe.name} v{recipe.version}...") - fetcher.fetch(recipe, cache.src_dir(recipe)) - - with log_lock: - log.step(f" Building {recipe.name} v{recipe.version}...") - - dep_dirs = [] - for dep in recipe.dependencies: - with dirs_lock: - dep_dir = install_dirs.get(dep) - if dep_dir is None: - raise BuildError( - f"Dependency '{dep}' of '{recipe.name}' was not built. " - "Check that all recipes are available." - ) - dep_dirs.append(dep_dir) - - install_dir = builder.build(recipe, dep_install_dirs=dep_dirs) - with dirs_lock: - install_dirs[name] = install_dir - with log_lock: - log.success(f" {recipe.name} v{recipe.version} — built ✓") - return install_dir - - def note_skipped(name: str, _cause: BaseException) -> None: - with log_lock: - log.warning(f" {name} — skipped (a dependency failed)") - - if jobs > 1: - log.debug(f"Building packages with up to {jobs} concurrent jobs") - - run_graph(graph, build_one, jobs=jobs, on_skip=note_skipped) - - # Update lockfile - lockfile.lock(resolved) - lockfile.save() - log.debug(f"Lockfile written: {lock_path}") - - # Build PackagePaths for ninja - package_paths: Dict[str, PackagePaths] = {} - for recipe in resolved: - idir = install_dirs.get(recipe.name) - if idir: - inc = idir / "include" - lib = idir / "lib" - libs = _detect_libraries(lib, recipe.name) - package_paths[recipe.name] = PackagePaths( - include_dirs=[inc] if inc.exists() else [], - lib_dirs=[lib] if lib.exists() else [], - libraries=libs, - ) - - return package_paths - - +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""CLI commands for ebuild using Click. + +Provides build, clean, configure, info, install, add, list-packages, +pipeline, and hardware analysis commands. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import threading +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, TYPE_CHECKING + +if TYPE_CHECKING: + from ebuild.eos_ai.eos_hw_analyzer import HardwareProfile + +import click +import yaml + +from ebuild import __version__ +from ebuild.build.ninja_backend import NinjaBackend, PackagePaths +from ebuild.build.toolchain import resolve_toolchain +from ebuild.cli.logger import Logger +from ebuild.core.config import ConfigError, load_config, ProjectConfig +from ebuild.core.graph import CycleError, DependencyGraph, build_dependency_graph +from ebuild.core.scheduler import run_graph +from ebuild.packages.builder import BuildError, PackageBuilder +from ebuild.packages.cache import PackageCache +from ebuild.packages.fetcher import FetchError, PackageFetcher +from ebuild.packages.lockfile import Lockfile +from ebuild.packages.recipe import RecipeError +from ebuild.packages.registry import create_registry +from ebuild.packages.resolver import PackageResolver, ResolveError + + +pass_logger = click.make_pass_decorator(Logger, ensure=True) + +# Default recipe search paths (relative to project root) +_RECIPE_DIRS = ["recipes"] + + +def _find_recipe_dirs(project_dir: Path) -> List[Path]: + """Locate recipe directories: project-local and install-level.""" + dirs = [] + for name in _RECIPE_DIRS: + d = project_dir / name + if d.is_dir(): + dirs.append(d) + + # Also check ebuild install location + pkg_recipes = Path(__file__).resolve().parent.parent.parent / "recipes" + if pkg_recipes.is_dir() and pkg_recipes not in dirs: + dirs.append(pkg_recipes) + + return dirs + + +def _install_packages( + cfg: ProjectConfig, + build_dir: Path, + log: Logger, + verbose: bool = False, + jobs: int = 1, +) -> Dict[str, PackagePaths]: + """Resolve, fetch, build, and return PackagePaths for all declared packages. + + Args: + jobs: Maximum packages to build concurrently. 1 (the default) preserves + the sequential build order exactly. + + Returns a dict mapping package name to PackagePaths for use by NinjaBackend. + """ + if not cfg.packages: + return {} + + log.step("Resolving packages...") + + recipe_dirs = _find_recipe_dirs(cfg.source_dir) + if not recipe_dirs: + log.warning("No recipe directories found. Create a 'recipes/' directory.") + return {} + + registry = create_registry(*recipe_dirs) + log.debug(f"Registry: {registry.package_count} recipes from {[str(p) for p in registry.search_paths]}") + + resolver = PackageResolver(registry) + requested = [{"name": p.name, "version": p.version} for p in cfg.packages] + resolved = resolver.resolve(requested) + + log.info(f"Packages to install: {', '.join(r.name + ' v' + r.version for r in resolved)}") + + # Lockfile + lock_path = cfg.source_dir / Lockfile.FILENAME + lockfile = Lockfile(lock_path) + + # Cache and fetcher + pkg_cache_dir = build_dir / "packages" + cache = PackageCache(pkg_cache_dir) + fetcher = PackageFetcher(pkg_cache_dir / "_downloads") + + # Build each package, honouring dependency order. Independent packages run + # concurrently when jobs > 1. + builder = PackageBuilder(cache, verbose=verbose) + install_dirs: Dict[str, Path] = {} + by_name = {r.name: r for r in resolved} + + graph = DependencyGraph() + for recipe in resolved: + graph.add_node(recipe.name) + for recipe in resolved: + for dep in recipe.dependencies: + if dep in by_name: + graph.add_edge(recipe.name, dep) + + dirs_lock = threading.Lock() + log_lock = threading.Lock() + + def build_one(name: str) -> Path: + recipe = by_name[name] + + if cache.is_built(recipe): + with dirs_lock: + install_dirs[name] = cache.install_dir(recipe) + with log_lock: + log.info(f" {recipe.name} v{recipe.version} — cached ✓") + return install_dirs[name] + + with log_lock: + log.step(f" Fetching {recipe.name} v{recipe.version}...") + fetcher.fetch(recipe, cache.src_dir(recipe)) + + with log_lock: + log.step(f" Building {recipe.name} v{recipe.version}...") + + dep_dirs = [] + for dep in recipe.dependencies: + with dirs_lock: + dep_dir = install_dirs.get(dep) + if dep_dir is None: + raise BuildError( + f"Dependency '{dep}' of '{recipe.name}' was not built. " + "Check that all recipes are available." + ) + dep_dirs.append(dep_dir) + + install_dir = builder.build(recipe, dep_install_dirs=dep_dirs) + with dirs_lock: + install_dirs[name] = install_dir + with log_lock: + log.success(f" {recipe.name} v{recipe.version} — built ✓") + return install_dir + + def note_skipped(name: str, _cause: BaseException) -> None: + with log_lock: + log.warning(f" {name} — skipped (a dependency failed)") + + if jobs > 1: + log.debug(f"Building packages with up to {jobs} concurrent jobs") + + run_graph(graph, build_one, jobs=jobs, on_skip=note_skipped) + + # Update lockfile + lockfile.lock(resolved) + lockfile.save() + log.debug(f"Lockfile written: {lock_path}") + + # Build PackagePaths for ninja + package_paths: Dict[str, PackagePaths] = {} + for recipe in resolved: + idir = install_dirs.get(recipe.name) + if idir: + inc = idir / "include" + lib = idir / "lib" + libs = _detect_libraries(lib, recipe.name) + package_paths[recipe.name] = PackagePaths( + include_dirs=[inc] if inc.exists() else [], + lib_dirs=[lib] if lib.exists() else [], + libraries=libs, + ) + + return package_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(): return [pkg_name] - - libs = [] - for f in sorted(lib_dir.iterdir()): - name = f.name - if name.startswith("lib") and (name.endswith(".a") or name.endswith(".so")): - lib_name = name[3:] # strip "lib" - if lib_name.endswith(".a"): - lib_name = lib_name[:-2] - elif lib_name.endswith(".so"): - lib_name = lib_name[:-3] - if lib_name and lib_name not in libs: - libs.append(lib_name) + + libs = [] + for f in sorted(lib_dir.iterdir()): + name = f.name + if name.startswith("lib") and (name.endswith(".a") or name.endswith(".so")): + lib_name = name[3:] # strip "lib" + if lib_name.endswith(".a"): + lib_name = lib_name[:-2] + elif lib_name.endswith(".so"): + lib_name = lib_name[:-3] + if lib_name and lib_name not in libs: + libs.append(lib_name) return libs if libs else [pkg_name] @@ -226,6 +226,137 @@ def _resolve_backend_request( return resolved_backend, backend_config +def _selected_board(default: str = "generic") -> str: + """The board this project targets, from its eos.yaml. + + Read rather than passed in: `ebuild build` takes no --board of its own in + the documented walk, so the value has to survive from `ebuild new` or + `ebuild configure`. + """ + path = Path("eos.yaml") + 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 + + +def _build_summary(cfg: "ProjectConfig", compiler, package_paths, log: Logger) -> None: + """The per-component summary the MLP walk ends with. + + A build that prints only "Build completed successfully" leaves the + developer to infer what was actually in it. The interesting case is a + package that resolved to nothing: the build still succeeds, the feature is + simply absent, and nothing said so. + """ + board = _selected_board(default="") + rows = [ + ("toolchain", getattr(compiler, "cc", "") or "cc", True), + ("board configuration", board or "host (no board recorded)", True), + ] + + declared = [p.name for p in getattr(cfg, "packages", []) or []] + for name in declared: + paths = (package_paths or {}).get(name) + # A package with no resolved include or library directory contributed + # nothing to this build, whatever build.yaml says. + resolved = bool(paths and (paths.include_dirs or paths.lib_dirs)) + rows.append((name, "" if resolved else "declared, nothing resolved", + resolved)) + + for target in cfg.targets: + if target.target_type in ("executable", "test"): + rows.append((target.name, target.target_type, True)) + + width = max(len(n) for n, _d, _ok in rows) + log.info("") + log.info("EmbeddedOS Build") + for name, detail, ok in rows: + mark = "OK " if ok else "MISS" + log.info(f" {mark} {name.ljust(width)}" + (f" {detail}" if detail else "")) + + missing = [n for n, _d, ok in rows if not ok] + if missing: + log.warning( + f"{len(missing)} declared package(s) resolved to nothing: " + + ", ".join(missing) + + ". The build succeeded without them." + ) + + +def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> None: + """Print how much of the board the build just used. + + The MLP walk ends with a build that says `Flash: 384 KB / RAM: 72 KB`. A + developer who has to run `size` themselves and remember which columns to + add is not being told; they are being left to find out. + + Never fatal. A footprint that cannot be measured -- no binutils, a cross + toolchain whose `size` is not installed -- is a missing convenience, and + failing a successful build over it would be worse than the silence it + replaces. + """ + from ebuild.build.footprint import ( + FootprintError, board_capacity, find_size_tool, format_report, + measure, over_budget, + ) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + return + + artifact = build_path / binaries[0].name + if not artifact.is_file(): + return + + prefix = getattr(cfg.toolchain, "target", None) or "host" + tool = find_size_tool(prefix) + if tool is None: + log.debug(f"no size tool for toolchain {prefix!r}; skipping footprint") + return + + try: + fp = measure(artifact, tool) + except FootprintError as exc: + log.debug(f"footprint unavailable: {exc}") + return + + board = _selected_board(default="") + flash_cap, ram_cap = board_capacity(board or None, _board_config()) + log.info("") + for line in format_report(fp, flash_cap, ram_cap).splitlines(): + log.info(line) + + exceeded = over_budget(fp, flash_cap, ram_cap) + if exceeded: + # Not a build failure: the image linked. It will not fit on the board, + # which the developer needs to hear now rather than from a device that + # will not boot. + log.warning(f"{exceeded} -- this image will not fit.") + else: + log.info("Ready to flash.") + + +def _board_config() -> Optional[Dict[str, Any]]: + """The project's own board description, if it ships one. + + A project that states its part's real capacity should not be measured + against the reference part for its family. + """ + path = Path("board.yaml") + if not path.is_file(): + return None + try: + import yaml + data = yaml.safe_load(path.read_text(encoding="utf-8")) + except Exception: + return None + return data if isinstance(data, dict) else None + + def _configure_ninja_backend( cfg: ProjectConfig, build_path: Path, @@ -288,349 +419,349 @@ def _format_missing_tool(exc: FileNotFoundError) -> str: if exc.filename: return f"Required tool or file not found: {exc.filename}" return str(exc) - - -# ═══════════════════════════════════════════════════════════════ -# Pipeline helper — shared by `pipeline` and `build --board` -# ═══════════════════════════════════════════════════════════════ - -def _run_pipeline_steps( - board: str, - hardware: Optional[str], - build_dir: Path, - log: Logger, -) -> Tuple[Any, Dict[str, Path], Dict[str, Path]]: - """Run the full pipeline: analyze -> generate configs -> generate eboot -> generate SDK. - - Returns (profile, config_outputs, boot_outputs). - """ - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - from ebuild.sdk_generator import generate_sdk - - configs_dir = build_dir / "configs" - sdk_dir = build_dir / "sdk" - configs_dir.mkdir(parents=True, exist_ok=True) - sdk_dir.mkdir(parents=True, exist_ok=True) - - # Step 1: Analyze hardware - log.step("[1/6] Analyzing hardware...") - analyzer = EosHardwareAnalyzer() - - if hardware: - hw_path = Path(hardware) - if not hw_path.exists(): - raise FileNotFoundError("Hardware file not found: " + hardware) - log.info(" Reading hardware design: " + str(hw_path)) - profile = analyzer.interpret_file(str(hw_path)) - else: - log.info(" Using board name: " + board) - profile = analyzer.interpret_text(board) - - # Override MCU from --board if the profile didn't detect one - if board and (not profile.mcu or profile.mcu.lower() != board.lower()): - mcu_info = analyzer.MCU_DATABASE.get(board.lower()) - if mcu_info: - profile.mcu = board.upper() - profile.arch = mcu_info["arch"] - profile.core = mcu_info["core"] - profile.vendor = mcu_info["vendor"] - profile.mcu_family = mcu_info["family"] - - log.info(" MCU: " + profile.mcu + " (" + profile.core + ")") - log.info(" Arch: " + profile.arch) - log.info(" Peripherals: " + str(len(profile.peripherals)) + " detected") - - # Step 2: Generate configs (board.yaml, boot.yaml, build.yaml, eos_product_config.h) - log.step("[2/6] Generating configs...") - config_gen = EosConfigGenerator(str(configs_dir)) - config_outputs = config_gen.generate_all(profile) - for name, path in config_outputs.items(): - log.success(" " + name + ": " + str(path)) - - # Step 3: Generate eboot integration (flash layout, linker, pack script, cmake defs) - log.step("[3/6] Generating eboot integration files...") - integrator = EosBootIntegrator(str(configs_dir)) - boot_outputs = integrator.generate_from_boot_yaml(str(config_outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(" " + name + ": " + str(path)) - - # Step 4: Generate SDK (toolchain.cmake, environment-setup, eboot target config) - log.step("[4/6] Generating SDK...") - target_name = board.lower() - generate_sdk(target_name, str(sdk_dir)) - log.success(" SDK generated in " + str(sdk_dir)) - - # Step 5: Copy generated headers to build include path - log.step("[5/6] Copying headers to build include path...") - include_dir = build_dir / "include" / "generated" - include_dir.mkdir(parents=True, exist_ok=True) - - for header_name in ["eos_product_config.h", "eboot_flash_layout.h"]: - src = configs_dir / header_name - if src.exists(): - dst = include_dir / header_name - shutil.copy2(str(src), str(dst)) - log.info(" " + header_name + " -> " + str(dst)) - - return profile, config_outputs, boot_outputs - - -def _run_cmake_build(profile, board, source_dir, build_dir, log): - """Run cmake configure + build with EOS_ENABLE_* defines injected.""" - from ebuild.build.dispatch import BackendDispatcher - - enables = profile.get_eos_enables() - cmake_defines = {} - cmake_defines["EOS_BOARD"] = board.lower() - cmake_defines["EOS_ARCH"] = profile.arch or "arm" - cmake_defines["EOS_CORE"] = profile.core or "cortex-m4" - - for flag, val in enables.items(): - cmake_defines[flag] = "ON" if val else "OFF" - - # Point cmake to generated config headers - gen_include = build_dir / "include" / "generated" - if gen_include.exists(): - cmake_defines["EOS_GENERATED_INCLUDE_DIR"] = gen_include.as_posix() - - # Point to eboot cmake defs if present - eboot_cmake = build_dir / "configs" / "eboot_config.cmake" - if eboot_cmake.exists(): - cmake_defines["EBOOT_CONFIG_FILE"] = eboot_cmake.as_posix() - - log.step("[6/6] Building with cmake...") - log.info(" Defines: " + str(len(cmake_defines)) + " cmake variables") - - dispatcher = BackendDispatcher(source_dir, build_dir) - - log.step(" Configuring (cmake)...") - dispatcher.configure(backend="cmake", config={"defines": cmake_defines}) - - log.step(" Building (cmake)...") - dispatcher.build(backend="cmake", config={}) - - -def _run_pack_image(build_dir, log): - """Run pack_image.sh if it exists and firmware output is present.""" - pack_script = build_dir / "configs" / "pack_image.sh" - if not pack_script.exists(): - return - - firmware_candidates = list(build_dir.glob("*.bin")) + list(build_dir.glob("*.elf")) - if not firmware_candidates: - log.info("No firmware binary found -- skipping image packing.") - return - - firmware = firmware_candidates[0] - log.step("Packing firmware image: " + firmware.name + "...") - - if os.name == "nt": - log.info(" Pack script is a bash script -- skipping on Windows.") - log.info(" Run manually: bash " + str(pack_script) + " " + str(firmware)) - else: - try: - subprocess.run( - ["bash", str(pack_script), str(firmware)], - check=True, - cwd=str(build_dir), - ) - log.success(" Firmware image packed.") - except subprocess.CalledProcessError as e: - log.warning(" Pack script failed: " + str(e)) - - -def _get_target_class(board): - """Look up the target class (mcu, sbc, soc, pc, virtual, devboard) for a board.""" - from ebuild.sdk_generator import TARGET_ARCH - info = TARGET_ARCH.get(board.lower()) - if info: - return info.get("class", "mcu") - return "mcu" - - -def _generate_image(board, build_dir, log): - """Generate a testable image based on target class. - - MCU targets: handled by _run_pack_image() (firmware .bin). - Linux-class targets: assemble rootfs + create tar.gz disk image. - """ - from ebuild.system.rootfs import RootfsBuilder - from ebuild.system.image import ImageBuilder - - target_class = _get_target_class(board) - - if target_class == "mcu": - _run_pack_image(build_dir, log) - return None - - # Linux-class target: assemble rootfs + create disk image - log.step("[7/7] Generating system image...") - - # Assemble rootfs skeleton - log.info(" Assembling rootfs...") - rootfs_builder = RootfsBuilder(build_dir) - rootfs_dir = rootfs_builder.assemble( - init_system="busybox", - hostname="eos-" + board.lower(), - ) - log.success(" Rootfs assembled: " + str(rootfs_dir)) - - # Copy built libraries into rootfs - lib_dest = rootfs_dir / "usr" / "lib" / "eos" - lib_dest.mkdir(parents=True, exist_ok=True) - lib_count = 0 - for lib_file in build_dir.glob("*.a"): - shutil.copy2(str(lib_file), str(lib_dest / lib_file.name)) - lib_count += 1 - # Also check subdirectories for libraries - for lib_file in build_dir.rglob("*.a"): - dest = lib_dest / lib_file.name - if not dest.exists(): - shutil.copy2(str(lib_file), str(dest)) - lib_count += 1 - if lib_count > 0: - log.info(" Installed " + str(lib_count) + " libraries into rootfs") - - # Copy generated headers into rootfs - gen_include = build_dir / "include" / "generated" - if gen_include.exists(): - inc_dest = rootfs_dir / "usr" / "include" / "eos" - inc_dest.mkdir(parents=True, exist_ok=True) - header_count = 0 - for header in gen_include.glob("*.h"): - shutil.copy2(str(header), str(inc_dest / header.name)) - header_count += 1 - if header_count > 0: - log.info(" Installed " + str(header_count) + " headers into rootfs") - - # Copy SDK info into rootfs - sdk_dir = build_dir / "sdk" - if sdk_dir.exists(): - sdk_dest = rootfs_dir / "opt" / "eos-sdk" - sdk_dest.mkdir(parents=True, exist_ok=True) - for item in sdk_dir.rglob("*"): - if item.is_file(): - rel = item.relative_to(sdk_dir) - dest = sdk_dest / rel - dest.parent.mkdir(parents=True, exist_ok=True) - shutil.copy2(str(item), str(dest)) - - # Create disk image (tar.gz — cross-platform, always works) - log.info(" Creating disk image...") - imager = ImageBuilder(build_dir, log=log) - image_path = imager.create( - rootfs_dir=rootfs_dir, - image_format="tar", - label="eos-" + board.lower(), - ) - log.success(" Image created: " + str(image_path)) - - # Report image size - if image_path.exists(): - size_kb = image_path.stat().st_size // 1024 - if size_kb > 1024: - log.info(" Size: " + str(size_kb // 1024) + " MB") - else: - log.info(" Size: " + str(size_kb) + " KB") - - return image_path - - -@click.group() -@click.version_option(version=__version__, prog_name="ebuild") -@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.") -@click.pass_context -def cli(ctx: click.Context, verbose: bool) -> None: - """ebuild — A unified embedded OS build system.""" - ctx.ensure_object(dict) - ctx.obj = Logger(verbose=verbose) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--backend", - default=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). Triggers full pipeline before build.", -) -@click.option( - "--hardware", - default=None, - type=click.Path(exists=True), - help="Hardware design file (.kicad_sch, .sch, .csv). Used with --board for analysis.", -) -@click.option( - "-j", - "--jobs", - default=1, - type=click.IntRange(min=1), - help=( - "Number of packages to build concurrently (default 1). Independent " - "packages are built in parallel; dependency order is always honoured. " - "Each package's own build may already run parallel compile jobs, so " - "large values can oversubscribe the machine." - ), -) -@click.pass_obj -def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], - board: Optional[str], hardware: Optional[str], jobs: int = 1) -> None: - """Parse config, detect backend, and build the project. - - When --board is provided, runs the full pipeline (analyze -> generate -> - build) before the normal cmake build. Generated configs are stored in - _build/configs/ and EOS_ENABLE_* defines are passed to cmake automatically. - """ - log.header("ebuild — Build") - - build_path = Path(build_dir) - - try: - # Pipeline mode: --board triggers full analyze -> generate -> build - if board: - log.info("Board pipeline mode: " + board) - - profile, config_outputs, boot_outputs = _run_pipeline_steps( - board=board, - hardware=hardware, - build_dir=build_path, - log=log, - ) - - source_dir = Path(".") - if (source_dir / "CMakeLists.txt").exists(): - _run_cmake_build(profile, board, source_dir, build_path, log) - else: - log.info("No CMakeLists.txt found -- pipeline steps complete (no cmake build).") - - _generate_image(board, build_path, log) - - log.success("Build completed successfully (pipeline mode).") - return - - # Normal mode: standard config-based build - log.step("Loading configuration...") - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - + + +# ═══════════════════════════════════════════════════════════════ +# Pipeline helper — shared by `pipeline` and `build --board` +# ═══════════════════════════════════════════════════════════════ + +def _run_pipeline_steps( + board: str, + hardware: Optional[str], + build_dir: Path, + log: Logger, +) -> Tuple[Any, Dict[str, Path], Dict[str, Path]]: + """Run the full pipeline: analyze -> generate configs -> generate eboot -> generate SDK. + + Returns (profile, config_outputs, boot_outputs). + """ + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + from ebuild.sdk_generator import generate_sdk + + configs_dir = build_dir / "configs" + sdk_dir = build_dir / "sdk" + configs_dir.mkdir(parents=True, exist_ok=True) + sdk_dir.mkdir(parents=True, exist_ok=True) + + # Step 1: Analyze hardware + log.step("[1/6] Analyzing hardware...") + analyzer = EosHardwareAnalyzer() + + if hardware: + hw_path = Path(hardware) + if not hw_path.exists(): + raise FileNotFoundError("Hardware file not found: " + hardware) + log.info(" Reading hardware design: " + str(hw_path)) + profile = analyzer.interpret_file(str(hw_path)) + else: + log.info(" Using board name: " + board) + profile = analyzer.interpret_text(board) + + # Override MCU from --board if the profile didn't detect one + if board and (not profile.mcu or profile.mcu.lower() != board.lower()): + mcu_info = analyzer.MCU_DATABASE.get(board.lower()) + if mcu_info: + profile.mcu = board.upper() + profile.arch = mcu_info["arch"] + profile.core = mcu_info["core"] + profile.vendor = mcu_info["vendor"] + profile.mcu_family = mcu_info["family"] + + log.info(" MCU: " + profile.mcu + " (" + profile.core + ")") + log.info(" Arch: " + profile.arch) + log.info(" Peripherals: " + str(len(profile.peripherals)) + " detected") + + # Step 2: Generate configs (board.yaml, boot.yaml, build.yaml, eos_product_config.h) + log.step("[2/6] Generating configs...") + config_gen = EosConfigGenerator(str(configs_dir)) + config_outputs = config_gen.generate_all(profile) + for name, path in config_outputs.items(): + log.success(" " + name + ": " + str(path)) + + # Step 3: Generate eboot integration (flash layout, linker, pack script, cmake defs) + log.step("[3/6] Generating eboot integration files...") + integrator = EosBootIntegrator(str(configs_dir)) + boot_outputs = integrator.generate_from_boot_yaml(str(config_outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(" " + name + ": " + str(path)) + + # Step 4: Generate SDK (toolchain.cmake, environment-setup, eboot target config) + log.step("[4/6] Generating SDK...") + target_name = board.lower() + generate_sdk(target_name, str(sdk_dir)) + log.success(" SDK generated in " + str(sdk_dir)) + + # Step 5: Copy generated headers to build include path + log.step("[5/6] Copying headers to build include path...") + include_dir = build_dir / "include" / "generated" + include_dir.mkdir(parents=True, exist_ok=True) + + for header_name in ["eos_product_config.h", "eboot_flash_layout.h"]: + src = configs_dir / header_name + if src.exists(): + dst = include_dir / header_name + shutil.copy2(str(src), str(dst)) + log.info(" " + header_name + " -> " + str(dst)) + + return profile, config_outputs, boot_outputs + + +def _run_cmake_build(profile, board, source_dir, build_dir, log): + """Run cmake configure + build with EOS_ENABLE_* defines injected.""" + from ebuild.build.dispatch import BackendDispatcher + + enables = profile.get_eos_enables() + cmake_defines = {} + cmake_defines["EOS_BOARD"] = board.lower() + cmake_defines["EOS_ARCH"] = profile.arch or "arm" + cmake_defines["EOS_CORE"] = profile.core or "cortex-m4" + + for flag, val in enables.items(): + cmake_defines[flag] = "ON" if val else "OFF" + + # Point cmake to generated config headers + gen_include = build_dir / "include" / "generated" + if gen_include.exists(): + cmake_defines["EOS_GENERATED_INCLUDE_DIR"] = gen_include.as_posix() + + # Point to eboot cmake defs if present + eboot_cmake = build_dir / "configs" / "eboot_config.cmake" + if eboot_cmake.exists(): + cmake_defines["EBOOT_CONFIG_FILE"] = eboot_cmake.as_posix() + + log.step("[6/6] Building with cmake...") + log.info(" Defines: " + str(len(cmake_defines)) + " cmake variables") + + dispatcher = BackendDispatcher(source_dir, build_dir) + + log.step(" Configuring (cmake)...") + dispatcher.configure(backend="cmake", config={"defines": cmake_defines}) + + log.step(" Building (cmake)...") + dispatcher.build(backend="cmake", config={}) + + +def _run_pack_image(build_dir, log): + """Run pack_image.sh if it exists and firmware output is present.""" + pack_script = build_dir / "configs" / "pack_image.sh" + if not pack_script.exists(): + return + + firmware_candidates = list(build_dir.glob("*.bin")) + list(build_dir.glob("*.elf")) + if not firmware_candidates: + log.info("No firmware binary found -- skipping image packing.") + return + + firmware = firmware_candidates[0] + log.step("Packing firmware image: " + firmware.name + "...") + + if os.name == "nt": + log.info(" Pack script is a bash script -- skipping on Windows.") + log.info(" Run manually: bash " + str(pack_script) + " " + str(firmware)) + else: + try: + subprocess.run( + ["bash", str(pack_script), str(firmware)], + check=True, + cwd=str(build_dir), + ) + log.success(" Firmware image packed.") + except subprocess.CalledProcessError as e: + log.warning(" Pack script failed: " + str(e)) + + +def _get_target_class(board): + """Look up the target class (mcu, sbc, soc, pc, virtual, devboard) for a board.""" + from ebuild.sdk_generator import TARGET_ARCH + info = TARGET_ARCH.get(board.lower()) + if info: + return info.get("class", "mcu") + return "mcu" + + +def _generate_image(board, build_dir, log): + """Generate a testable image based on target class. + + MCU targets: handled by _run_pack_image() (firmware .bin). + Linux-class targets: assemble rootfs + create tar.gz disk image. + """ + from ebuild.system.rootfs import RootfsBuilder + from ebuild.system.image import ImageBuilder + + target_class = _get_target_class(board) + + if target_class == "mcu": + _run_pack_image(build_dir, log) + return None + + # Linux-class target: assemble rootfs + create disk image + log.step("[7/7] Generating system image...") + + # Assemble rootfs skeleton + log.info(" Assembling rootfs...") + rootfs_builder = RootfsBuilder(build_dir) + rootfs_dir = rootfs_builder.assemble( + init_system="busybox", + hostname="eos-" + board.lower(), + ) + log.success(" Rootfs assembled: " + str(rootfs_dir)) + + # Copy built libraries into rootfs + lib_dest = rootfs_dir / "usr" / "lib" / "eos" + lib_dest.mkdir(parents=True, exist_ok=True) + lib_count = 0 + for lib_file in build_dir.glob("*.a"): + shutil.copy2(str(lib_file), str(lib_dest / lib_file.name)) + lib_count += 1 + # Also check subdirectories for libraries + for lib_file in build_dir.rglob("*.a"): + dest = lib_dest / lib_file.name + if not dest.exists(): + shutil.copy2(str(lib_file), str(dest)) + lib_count += 1 + if lib_count > 0: + log.info(" Installed " + str(lib_count) + " libraries into rootfs") + + # Copy generated headers into rootfs + gen_include = build_dir / "include" / "generated" + if gen_include.exists(): + inc_dest = rootfs_dir / "usr" / "include" / "eos" + inc_dest.mkdir(parents=True, exist_ok=True) + header_count = 0 + for header in gen_include.glob("*.h"): + shutil.copy2(str(header), str(inc_dest / header.name)) + header_count += 1 + if header_count > 0: + log.info(" Installed " + str(header_count) + " headers into rootfs") + + # Copy SDK info into rootfs + sdk_dir = build_dir / "sdk" + if sdk_dir.exists(): + sdk_dest = rootfs_dir / "opt" / "eos-sdk" + sdk_dest.mkdir(parents=True, exist_ok=True) + for item in sdk_dir.rglob("*"): + if item.is_file(): + rel = item.relative_to(sdk_dir) + dest = sdk_dest / rel + dest.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(str(item), str(dest)) + + # Create disk image (tar.gz — cross-platform, always works) + log.info(" Creating disk image...") + imager = ImageBuilder(build_dir, log=log) + image_path = imager.create( + rootfs_dir=rootfs_dir, + image_format="tar", + label="eos-" + board.lower(), + ) + log.success(" Image created: " + str(image_path)) + + # Report image size + if image_path.exists(): + size_kb = image_path.stat().st_size // 1024 + if size_kb > 1024: + log.info(" Size: " + str(size_kb // 1024) + " MB") + else: + log.info(" Size: " + str(size_kb) + " KB") + + return image_path + + +@click.group() +@click.version_option(version=__version__, prog_name="ebuild") +@click.option("-v", "--verbose", is_flag=True, help="Enable verbose output.") +@click.pass_context +def cli(ctx: click.Context, verbose: bool) -> None: + """ebuild — A unified embedded OS build system.""" + ctx.ensure_object(dict) + ctx.obj = Logger(verbose=verbose) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--backend", + default=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). Triggers full pipeline before build.", +) +@click.option( + "--hardware", + default=None, + type=click.Path(exists=True), + help="Hardware design file (.kicad_sch, .sch, .csv). Used with --board for analysis.", +) +@click.option( + "-j", + "--jobs", + default=1, + type=click.IntRange(min=1), + help=( + "Number of packages to build concurrently (default 1). Independent " + "packages are built in parallel; dependency order is always honoured. " + "Each package's own build may already run parallel compile jobs, so " + "large values can oversubscribe the machine." + ), +) +@click.pass_obj +def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], + board: Optional[str], hardware: Optional[str], jobs: int = 1) -> None: + """Parse config, detect backend, and build the project. + + When --board is provided, runs the full pipeline (analyze -> generate -> + build) before the normal cmake build. Generated configs are stored in + _build/configs/ and EOS_ENABLE_* defines are passed to cmake automatically. + """ + log.header("ebuild — Build") + + build_path = Path(build_dir) + + try: + # Pipeline mode: --board triggers full analyze -> generate -> build + if board: + log.info("Board pipeline mode: " + board) + + profile, config_outputs, boot_outputs = _run_pipeline_steps( + board=board, + hardware=hardware, + build_dir=build_path, + log=log, + ) + + source_dir = Path(".") + if (source_dir / "CMakeLists.txt").exists(): + _run_cmake_build(profile, board, source_dir, build_path, log) + else: + log.info("No CMakeLists.txt found -- pipeline steps complete (no cmake build).") + + _generate_image(board, build_path, log) + + log.success("Build completed successfully (pipeline mode).") + return + + # Normal mode: standard config-based build + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + build_path = Path(build_dir) resolved_backend, backend_config = _resolve_backend_request( cfg=cfg, @@ -638,77 +769,79 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], source_dir=cfg.source_dir, log=log, ) - - # Route: external build systems (cmake, make, meson, cargo, kbuild) - # go through the dispatcher. ebuild's own ninja backend handles - # projects with targets defined in build.yaml. - if resolved_backend != "ninja" or not cfg.targets: - from ebuild.build.dispatch import BackendDispatcher - - log.step(f"Using {resolved_backend} backend...") - dispatcher = BackendDispatcher(cfg.source_dir, build_path) - - # Tier 2+3: configure first - from ebuild.build.dispatch import TIER_1 - if resolved_backend not in TIER_1: + + # Route: external build systems (cmake, make, meson, cargo, kbuild) + # go through the dispatcher. ebuild's own ninja backend handles + # projects with targets defined in build.yaml. + if resolved_backend != "ninja" or not cfg.targets: + from ebuild.build.dispatch import BackendDispatcher + + log.step(f"Using {resolved_backend} backend...") + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + + # Tier 2+3: configure first + from ebuild.build.dispatch import TIER_1 + if resolved_backend not in TIER_1: log.step(f"Configuring ({resolved_backend})...") dispatcher.configure( backend=resolved_backend, config=backend_config, ) - - # Build - log.step(f"Building ({resolved_backend})...") + + # Build + log.step(f"Building ({resolved_backend})...") dispatcher.build( backend=resolved_backend, config=backend_config, ) - - log.success(f"Build completed successfully ({resolved_backend}).") - return - - # ebuild's own Ninja backend path (build.yaml with targets) - log.step("Resolving dependency graph...") - graph = build_dependency_graph(cfg.targets) - build_order = graph.topological_sort() - log.debug(f"Build order: {' → '.join(build_order)}") - - log.step("Resolving toolchain...") - compiler = resolve_toolchain(cfg.toolchain) - 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) - - log.step(f"Generating build.ninja in {build_path}/...") - ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) - ninja_backend.generate() - log.success(f"Generated {build_path / 'build.ninja'}") - 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")] - if log.verbose: - ninja_cmd.append("-v") - - result = subprocess.run(ninja_cmd, capture_output=not log.verbose, cwd=str(cfg.source_dir)) - if result.returncode != 0: - # ninja reports compiler diagnostics on stdout, not stderr, so a - # failure surfaced only through stderr says nothing about what broke. - # Replay both streams verbatim rather than through log.error(), which - # would prefix a multi-line diagnostic with a single "[error]" tag. - if not log.verbose: - if result.stdout: - sys.stdout.write(result.stdout.decode(errors="replace")) - sys.stdout.flush() - if result.stderr: - sys.stderr.write(result.stderr.decode(errors="replace")) - sys.stderr.flush() - log.error("Build failed.") - raise SystemExit(1) - - log.success("Build completed successfully.") - + + log.success(f"Build completed successfully ({resolved_backend}).") + return + + # ebuild's own Ninja backend path (build.yaml with targets) + log.step("Resolving dependency graph...") + graph = build_dependency_graph(cfg.targets) + build_order = graph.topological_sort() + log.debug(f"Build order: {' → '.join(build_order)}") + + log.step("Resolving toolchain...") + compiler = resolve_toolchain(cfg.toolchain) + 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) + + log.step(f"Generating build.ninja in {build_path}/...") + ninja_backend = NinjaBackend(cfg, build_path, compiler, package_paths=package_paths) + ninja_backend.generate() + log.success(f"Generated {build_path / 'build.ninja'}") + 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")] + if log.verbose: + ninja_cmd.append("-v") + + result = subprocess.run(ninja_cmd, capture_output=not log.verbose, cwd=str(cfg.source_dir)) + if result.returncode != 0: + # ninja reports compiler diagnostics on stdout, not stderr, so a + # failure surfaced only through stderr says nothing about what broke. + # Replay both streams verbatim rather than through log.error(), which + # would prefix a multi-line diagnostic with a single "[error]" tag. + if not log.verbose: + if result.stdout: + sys.stdout.write(result.stdout.decode(errors="replace")) + sys.stdout.flush() + if result.stderr: + sys.stderr.write(result.stderr.decode(errors="replace")) + sys.stderr.flush() + log.error("Build failed.") + raise SystemExit(1) + + log.success("Build completed successfully.") + _build_summary(cfg, compiler, package_paths, log) + _report_footprint(cfg, build_path, log) + except FileNotFoundError as e: log.error(_format_missing_tool(e)) raise SystemExit(1) @@ -718,141 +851,141 @@ def build(log: Logger, config_path: str, build_dir: str, backend: Optional[str], except (ConfigError, RecipeError) as e: log.error(f"Configuration error: {e}") raise SystemExit(1) - except CycleError as e: - log.error(f"Dependency error: {e}") - raise SystemExit(1) - except (ResolveError, FetchError, BuildError) as e: - log.error(f"Package error: {e}") - raise SystemExit(1) - except RuntimeError as e: - log.error(str(e)) - raise SystemExit(1) - - -@cli.command() -@click.option( - "--board", - required=True, - help="Target board name (e.g., stm32f4, nrf52, stm32h7).", -) -@click.option( - "--hardware", - default=None, - type=click.Path(exists=True), - help="Hardware design file (.kicad_sch, .sch, .csv) for schematic analysis.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--skip-build", - is_flag=True, - default=False, - help="Only generate configs and SDK -- skip cmake build.", -) -@click.pass_obj -def pipeline(log: Logger, board: str, hardware: Optional[str], - build_dir: str, skip_build: bool) -> None: - """Run the full end-to-end build pipeline for a target board. - - Chains: analyze hardware -> generate configs -> generate eboot integration -> - generate SDK -> cmake build -> pack firmware image. - - Examples:\n - ebuild pipeline --board stm32f4\n - ebuild pipeline --board stm32f4 --hardware board.kicad_sch\n - ebuild pipeline --board nrf52 --skip-build - """ - log.header("ebuild — Full Pipeline") - - build_path = Path(build_dir) - - try: - profile, config_outputs, boot_outputs = _run_pipeline_steps( - board=board, - hardware=hardware, - build_dir=build_path, - log=log, - ) - - if skip_build: - log.info("--skip-build: skipping cmake build and image generation.") - else: - source_dir = Path(".") - if (source_dir / "CMakeLists.txt").exists(): - _run_cmake_build(profile, board, source_dir, build_path, log) - else: - log.info("No CMakeLists.txt found -- skipping cmake build step.") - - _generate_image(board, build_path, log) - - # Summary - log.header("Pipeline Summary") - configs_dir = build_path / "configs" - sdk_dir = build_path / "sdk" - images_dir = build_path / "images" - rootfs_dir = build_path / "rootfs" - if configs_dir.exists(): - config_files = list(configs_dir.iterdir()) - log.info(" Configs: " + str(len(config_files)) + " files in " + str(configs_dir)) - for f in sorted(config_files): - log.info(" " + f.name) - if sdk_dir.exists(): - sdk_subdirs = [d for d in sdk_dir.iterdir() if d.is_dir()] - log.info(" SDK: " + str(len(sdk_subdirs)) + " target(s) in " + str(sdk_dir)) - if images_dir.exists(): - image_files = list(images_dir.iterdir()) - log.info(" Images: " + str(len(image_files)) + " file(s) in " + str(images_dir)) - for f in sorted(image_files): - size_kb = f.stat().st_size // 1024 - size_str = str(size_kb // 1024) + " MB" if size_kb > 1024 else str(size_kb) + " KB" - log.info(" " + f.name + " (" + size_str + ")") - if rootfs_dir.exists(): - rootfs_dirs = [d for d in rootfs_dir.iterdir() if d.is_dir()] - log.info(" Rootfs: " + str(len(rootfs_dirs)) + " directories in " + str(rootfs_dir)) - - log.success("Pipeline completed successfully.") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except SystemExit: - raise - except Exception as e: - log.error("Pipeline failed: " + str(e)) - raise SystemExit(1) - - -@cli.command() -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory to remove.", -) -@click.pass_obj -def clean(log: Logger, build_dir: str) -> None: - """Remove the build output directory.""" - log.header("ebuild — Clean") - build_path = Path(build_dir) - - if build_path.exists(): - shutil.rmtree(build_path) - log.success(f"Removed {build_path}/") - else: - log.info(f"Nothing to clean — {build_path}/ does not exist.") - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) + except CycleError as e: + log.error(f"Dependency error: {e}") + raise SystemExit(1) + except (ResolveError, FetchError, BuildError) as e: + log.error(f"Package error: {e}") + raise SystemExit(1) + except RuntimeError as e: + log.error(str(e)) + raise SystemExit(1) + + +@cli.command() +@click.option( + "--board", + required=True, + help="Target board name (e.g., stm32f4, nrf52, stm32h7).", +) +@click.option( + "--hardware", + default=None, + type=click.Path(exists=True), + help="Hardware design file (.kicad_sch, .sch, .csv) for schematic analysis.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--skip-build", + is_flag=True, + default=False, + help="Only generate configs and SDK -- skip cmake build.", +) +@click.pass_obj +def pipeline(log: Logger, board: str, hardware: Optional[str], + build_dir: str, skip_build: bool) -> None: + """Run the full end-to-end build pipeline for a target board. + + Chains: analyze hardware -> generate configs -> generate eboot integration -> + generate SDK -> cmake build -> pack firmware image. + + Examples:\n + ebuild pipeline --board stm32f4\n + ebuild pipeline --board stm32f4 --hardware board.kicad_sch\n + ebuild pipeline --board nrf52 --skip-build + """ + log.header("ebuild — Full Pipeline") + + build_path = Path(build_dir) + + try: + profile, config_outputs, boot_outputs = _run_pipeline_steps( + board=board, + hardware=hardware, + build_dir=build_path, + log=log, + ) + + if skip_build: + log.info("--skip-build: skipping cmake build and image generation.") + else: + source_dir = Path(".") + if (source_dir / "CMakeLists.txt").exists(): + _run_cmake_build(profile, board, source_dir, build_path, log) + else: + log.info("No CMakeLists.txt found -- skipping cmake build step.") + + _generate_image(board, build_path, log) + + # Summary + log.header("Pipeline Summary") + configs_dir = build_path / "configs" + sdk_dir = build_path / "sdk" + images_dir = build_path / "images" + rootfs_dir = build_path / "rootfs" + if configs_dir.exists(): + config_files = list(configs_dir.iterdir()) + log.info(" Configs: " + str(len(config_files)) + " files in " + str(configs_dir)) + for f in sorted(config_files): + log.info(" " + f.name) + if sdk_dir.exists(): + sdk_subdirs = [d for d in sdk_dir.iterdir() if d.is_dir()] + log.info(" SDK: " + str(len(sdk_subdirs)) + " target(s) in " + str(sdk_dir)) + if images_dir.exists(): + image_files = list(images_dir.iterdir()) + log.info(" Images: " + str(len(image_files)) + " file(s) in " + str(images_dir)) + for f in sorted(image_files): + size_kb = f.stat().st_size // 1024 + size_str = str(size_kb // 1024) + " MB" if size_kb > 1024 else str(size_kb) + " KB" + log.info(" " + f.name + " (" + size_str + ")") + if rootfs_dir.exists(): + rootfs_dirs = [d for d in rootfs_dir.iterdir() if d.is_dir()] + log.info(" Rootfs: " + str(len(rootfs_dirs)) + " directories in " + str(rootfs_dir)) + + log.success("Pipeline completed successfully.") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except SystemExit: + raise + except Exception as e: + log.error("Pipeline failed: " + str(e)) + raise SystemExit(1) + + +@cli.command() +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory to remove.", +) +@click.pass_obj +def clean(log: Logger, build_dir: str) -> None: + """Remove the build output directory.""" + log.header("ebuild — Clean") + build_path = Path(build_dir) + + if build_path.exists(): + shutil.rmtree(build_path) + log.success(f"Removed {build_path}/") + else: + log.info(f"Nothing to clean — {build_path}/ does not exist.") + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) @click.option( "--build-dir", default="_build", @@ -904,1076 +1037,1218 @@ def configure(log: Logger, config_path: str, build_dir: str, backend: Optional[s except (ConfigError, RecipeError) as e: log.error(f"Configuration error: {e}") raise SystemExit(1) - except (CycleError, ResolveError, FetchError, BuildError) as e: - log.error(f"Error: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def info(log: Logger, config_path: str) -> None: - """Show project info, targets, packages, and dependency graph.""" - log.header("ebuild — Project Info") - - try: - cfg = load_config(config_path) - - log.info(f"Project : {cfg.name}") - log.info(f"Version : {cfg.version}") - log.info(f"Source : {cfg.source_dir.resolve()}") - - if cfg.toolchain: - tc = cfg.toolchain - log.info(f"Compiler: {tc.compiler} (arch: {tc.arch})") - if tc.prefix: - log.info(f"Prefix : {tc.prefix}") - else: - log.info("Compiler: gcc (native)") - - if cfg.packages: - log.header("Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else "" - log.step(f"{p.name}{ver}") - - log.header("Targets") - for t in cfg.targets: - deps = f" depends=[{', '.join(t.depends)}]" if t.depends else "" - uses = f" uses=[{', '.join(t.uses)}]" if t.uses else "" - log.step(f"{t.name} ({t.target_type}){deps}{uses}") - if t.sources: - log.debug(f" sources: {t.sources}") - if t.cflags: - log.debug(f" cflags : {t.cflags}") - if t.ldflags: - log.debug(f" ldflags: {t.ldflags}") - - graph = build_dependency_graph(cfg.targets) - build_order = graph.topological_sort() - log.header("Build Order") - for i, name in enumerate(build_order, 1): - log.step(f"{i}. {name}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except ConfigError as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except CycleError as e: - log.error(f"Dependency error: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.pass_obj -def install(log: Logger, config_path: str, build_dir: str) -> None: - """Resolve, fetch, and build all declared packages.""" - log.header("ebuild — Install Packages") - - try: - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - if not cfg.packages: - log.info("No packages declared in build.yaml.") - return - - build_path = Path(build_dir) - _install_packages(cfg, build_path, log, verbose=log.verbose) - log.success("All packages installed successfully.") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except (ResolveError, FetchError, BuildError) as e: - log.error(f"Package error: {e}") - raise SystemExit(1) - - -@cli.command("add") -@click.argument("package_name") -@click.option("--version", "pkg_version", default=None, help="Package version to add.") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def add_package(log: Logger, package_name: str, pkg_version: Optional[str], config_path: str) -> None: - """Add a package dependency to build.yaml.""" - log.header("ebuild — Add Package") - - config_path_obj = Path(config_path) - if not config_path_obj.exists(): - log.error(f"Config file not found: {config_path}") - raise SystemExit(1) - - # Verify the package exists in registry - recipe_dirs = _find_recipe_dirs(config_path_obj.parent) - if recipe_dirs: - registry = create_registry(*recipe_dirs) - recipe = registry.get(package_name, pkg_version) - if recipe: - log.info(f"Found recipe: {recipe.name} v{recipe.version}") - if pkg_version is None: - pkg_version = recipe.version - else: - log.warning(f"No recipe found for '{package_name}' — adding anyway.") - - # Load and update config - with open(config_path_obj, "r", encoding="utf-8") as f: - raw = yaml.safe_load(f) - - if "packages" not in raw: - raw["packages"] = [] - - # Check for duplicates - for p in raw["packages"]: - if isinstance(p, dict) and p.get("name") == package_name: - log.info(f"Package '{package_name}' already in build.yaml.") - return - - entry: Dict[str, str] = {"name": package_name} - if pkg_version: - entry["version"] = pkg_version - - raw["packages"].append(entry) - - with open(config_path_obj, "w", encoding="utf-8") as f: - yaml.dump(raw, f, default_flow_style=False, sort_keys=False) - - log.success(f"Added {package_name}" + (f" v{pkg_version}" if pkg_version else "") + f" to {config_path}") - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--format", "img_format", - default="tar", - type=click.Choice(["raw", "qcow2", "tar", "ext4", "squashfs"]), - help="Output image format.", -) -@click.option( - "--size", "size_mb", - default=256, - type=int, - help="Image size in MB (for raw/ext4).", -) -@click.pass_obj -def system(log: Logger, config_path: str, build_dir: str, img_format: str, size_mb: int) -> None: - """Build a complete Linux system image (rootfs + kernel + image).""" - log.header("ebuild — System Image Build") - - try: - from ebuild.system.rootfs import RootfsBuilder - from ebuild.system.image import ImageBuilder - - build_path = Path(build_dir) - - log.step("Assembling root filesystem...") - rootfs = RootfsBuilder(build_path) - rootfs_dir = rootfs.assemble(init_system="busybox", hostname="eos") - log.success(f"Rootfs assembled: {rootfs_dir}") - - log.step(f"Creating {img_format} image...") - imager = ImageBuilder(build_path, log=log) - image_path = imager.create( - rootfs_dir=rootfs_dir, - image_format=img_format, - image_size_mb=size_mb, - ) - log.success(f"Image created: {image_path}") - - except Exception as e: - log.error(f"System build failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--rtos", - default="generic", - type=click.Choice(["zephyr", "freertos", "nuttx", "generic"]), - help="Target RTOS.", -) -@click.option( - "--board", - default="generic", - help="Target board name.", -) -@click.pass_obj -def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: str) -> None: - """Build RTOS firmware for an embedded target.""" - log.header("ebuild — Firmware Build") - - try: - from ebuild.firmware.firmware import FirmwareBuilder - - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = Path(build_dir) - builder = FirmwareBuilder(build_path, log=log) - - log.step(f"Building {rtos} firmware for {board}...") - output = builder.build( - source_dir=cfg.source_dir, - rtos=rtos, - board=board, - ) - log.success(f"Firmware built: {output}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Firmware build failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.argument("image", type=click.Path(exists=True)) -@click.option("--tool", default="openocd", - type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]), - help="Flash tool to use.") -@click.option("--target", default="stm32f4", help="Target MCU/board.") -@click.option("--address", default="0x08000000", help="Flash base address (hex).") -@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.") -@click.pass_obj -def flash(log: Logger, image: str, tool: str, target: str, address: str, - reset_after: bool) -> None: - """Flash a firmware image to the target device. - - Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash. - - Examples: - - ebuild flash firmware.bin --tool openocd --target stm32f4 - - ebuild flash app.bin --tool nrfjprog - - ebuild flash firmware.bin --tool esptool --address 0x10000 - - ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after - """ - log.header("ebuild — Flash") - - try: - from ebuild.firmware.flash import flash as do_flash, reset as do_reset, FlashError - - image_path = Path(image) - addr = int(address, 0) - - log.step(f"Flashing {image_path.name} to {target} via {tool}...") - log.info(f" Address: {hex(addr)}") - - do_flash(image_path, tool=tool, target=target, address=addr) - log.success(f"Flash complete: {image_path.name}") - - if reset_after: - log.step("Resetting target...") - do_reset(tool=tool, target=target) - log.success("Target reset.") - - except FlashError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Flash failed: {e}") - raise SystemExit(1) - - -@cli.command("list-packages") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def list_packages(log: Logger, config_path: str) -> None: - """List available package recipes and project packages.""" - log.header("ebuild — Package Registry") - - config_path_obj = Path(config_path) - project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") - - recipe_dirs = _find_recipe_dirs(project_dir) - if not recipe_dirs: - log.warning("No recipe directories found.") - return - - registry = create_registry(*recipe_dirs) - packages = registry.list_packages() - - if not packages: - log.info("No recipes found.") - return - - log.info(f"Available recipes ({len(packages)}):") - for recipe in packages: - deps = f" (depends: {', '.join(recipe.dependencies)})" if recipe.dependencies else "" - desc = f" — {recipe.description}" if recipe.description else "" - log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") - - # Show project packages if config exists - if config_path_obj.exists(): - try: - cfg = load_config(config_path_obj) - if cfg.packages: - log.header("Project Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else " (latest)" - status = "✓ recipe found" if registry.has(p.name, p.version) else "✗ no recipe" - log.step(f"{p.name}{ver} — {status}") - except (ConfigError, FileNotFoundError): - pass - - -@cli.command() -@click.argument("input_text", required=False) -@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (KiCad .kicad_sch, Eagle .sch, BOM .csv, YAML, text).") -@click.option("--output-dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.option("--llm", "use_llm", is_flag=True, default=False, help="Enable LLM-enhanced analysis (Ollama local or OPENAI_API_KEY).") -@click.pass_obj -def analyze(log: Logger, input_text: Optional[str], input_file: Optional[str], - output_dir: str, eos_schemas: Optional[str], use_llm: bool) -> None: - """Analyze hardware design and generate eos + eboot + ebuild configs. - - Accepts text description, KiCad schematic (.kicad_sch), Eagle schematic (.sch), - BOM CSV (.csv), or any text/YAML file. Auto-detects format by file extension. - - Generates board.yaml, boot.yaml, build.yaml, and eos_product_config.h. - - Examples: - - ebuild analyze "nRF52840 BLE sensor with I2C and SPI flash" - - ebuild analyze --file design.kicad_sch - - ebuild analyze --file design.sch - - ebuild analyze --file bom.csv - - ebuild analyze "STM32H7 with CAN Ethernet" --llm - """ - log.header("ebuild — Hardware Analysis") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - interpreter = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - if input_file: - path = Path(input_file) - log.step(f"Reading hardware design: {path}") - profile = interpreter.interpret_file(str(path)) - elif input_text: - log.step("Analyzing text description...") - profile = interpreter.interpret_text(input_text) - else: - log.error("Provide hardware description text or --file ") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - extra = "" - if p.config.get("i2c_addr"): - extra = f" (I2C addr: {p.config['i2c_addr']})" - log.info(f" - {p.peripheral_type}: {p.name}{extra}") - log.info(f"Confidence: {profile.confidence:.0%}") - - # Optional LLM-enhanced analysis - if use_llm: - log.step("Running LLM-enhanced analysis...") - llm_info = interpreter.llm_client.get_provider_info() - log.info(f" Provider: {llm_info}") - if interpreter.llm_client.is_available(): - profile = interpreter.analyze_with_llm(profile) - log.success(" LLM analysis complete") - else: - log.warning(" No LLM available. Install Ollama or set OPENAI_API_KEY.") - - log.step("Generating configs...") - generator = EosConfigGenerator(output_dir) - outputs = generator.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.step("Validating generated configs...") - validator = EosConfigValidator() - result = validator.validate_all(output_dir) - log.info(result.summary()) - - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - prompt = interpreter.generate_prompt(profile) - prompt_path = Path(output_dir) / "llm_prompt.txt" - prompt_path.write_text(prompt) - log.info(f"LLM prompt saved: {prompt_path}") - - log.success("Analysis complete.") - - except Exception as e: - log.error(f"Analysis failed: {e}") - raise SystemExit(1) - - -@cli.command("generate-project") -@click.option("--text", "input_text", default=None, help="Hardware description text.") -@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (YAML, KiCad, BOM).") -@click.option("--config", "config_yaml", type=click.Path(exists=True), help="Existing board.yaml from ebuild analyze.") -@click.option("--eos-repo", type=click.Path(exists=True), default=None, help="Path to local eos repo. Auto-clones from GitHub if omitted.") -@click.option("--eboot-repo", type=click.Path(exists=True), default=None, help="Path to local eboot repo. Auto-clones from GitHub if omitted.") -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default GitHub URL).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default GitHub URL).") -@click.option("--clone-dir", default=None, type=click.Path(), help="Directory to clone repos into. Uses temp dir if omitted.") -@click.option("--output", default="_project", help="Output directory (copy mode).") -@click.option("--mode", type=click.Choice(["copy", "branch"]), default="copy", help="Output mode.") -@click.option("--branch", default=None, help="Git branch name (branch mode only).") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_project( - log: Logger, - input_text: Optional[str], - input_file: Optional[str], - config_yaml: Optional[str], - eos_repo: Optional[str], - eboot_repo: Optional[str], - eos_url: Optional[str], - eboot_url: Optional[str], - clone_dir: Optional[str], - output: str, - mode: str, - branch: Optional[str], - eos_schemas: Optional[str], -) -> None: - """Generate a stripped-down eos/eboot project for specific hardware. - - Analyzes hardware requirements and prunes the full eos and eboot - repositories to only the modules needed for the target hardware. - Auto-clones eos and eboot from GitHub when local repo paths are not given. - - Examples: - - # Auto-clone from GitHub — no local repos needed: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --output customer-ble-sensor - - # With local repos: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --eos-repo ../eos --eboot-repo ../eboot --output customer-ble-sensor - - # From existing hardware analysis: - ebuild generate-project --config _generated/board.yaml \\ - --output gateway-project - - # Custom GitHub fork: - ebuild generate-project --text "STM32H7 industrial controller" \\ - --eos-url https://github.com/myorg/eos.git \\ - --eboot-url https://github.com/myorg/eboot.git \\ - --output industrial-project - - # Branch mode on local repos: - ebuild generate-project --config _generated/board.yaml \\ - --eos-repo ../eos --eboot-repo ../eboot \\ - --mode branch --branch customer/ble-sensor - """ - log.header("ebuild — Project Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_project_generator import EosProjectGenerator - - # Step 1: Obtain a HardwareProfile - if config_yaml: - log.step(f"Loading hardware profile from {config_yaml}...") - profile = _load_profile_from_board_yaml(config_yaml) - elif input_file: - log.step(f"Analyzing hardware design: {input_file}...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - path = Path(input_file) - if path.suffix == ".kicad_sch": - profile = analyzer.interpret_kicad(str(path)) - else: - content = path.read_text(encoding="utf-8", errors="replace") - if "," in content and len(content.split("\n")) > 2: - profile = analyzer.interpret_bom(content) - else: - profile = analyzer.interpret_text(content) - elif input_text: - log.step("Analyzing text description...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - profile = analyzer.interpret_text(input_text) - else: - log.error("Provide hardware description via --text, --file, or --config.") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - - # Step 2: Create generator and auto-clone repos if needed - generator = EosProjectGenerator( - eos_repo=eos_repo, - eboot_repo=eboot_repo, - eos_url=eos_url, - eboot_url=eboot_url, - ) - - if not eos_repo or not eboot_repo: - log.step("Cloning repos from GitHub (repos not provided locally)...") - generator.ensure_repos( - need_eos=(eos_repo is None), - need_eboot=(eboot_repo is None), - clone_dir=clone_dir, - ) - if generator.eos_repo and not eos_repo: - log.info(f" eos cloned to: {generator.eos_repo}") - if generator.eboot_repo and not eboot_repo: - log.info(f" eboot cloned to: {generator.eboot_repo}") - - manifest = generator.resolve_manifest(profile) - log.info(f"eos modules: {len(manifest.eos_dirs)} dirs, product={manifest.eos_product}") - log.info(f"eboot modules: {len(manifest.eboot_files)} core files, board={manifest.eboot_board}") - if manifest.eos_toolchain: - log.info(f"eos toolchain: {manifest.eos_toolchain}") - if manifest.eos_examples: - log.info(f"eos examples: {', '.join(manifest.eos_examples)}") - log.info(f"eos extras: {', '.join(manifest.eos_extras)}") - log.info(f"eboot extras: {', '.join(manifest.eboot_extras)}") - - log.step(f"Generating project ({mode} mode)...") - outputs = generator.generate( - profile=profile, - output=output, - mode=mode, - branch=branch, - ) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("Project generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Project generation failed: {e}") - raise SystemExit(1) - - -def _load_profile_from_board_yaml(board_yaml_path: str) -> "HardwareProfile": - """Load a HardwareProfile from a board.yaml produced by ``ebuild analyze``.""" - from ebuild.eos_ai.eos_hw_analyzer import ( - HardwareProfile, - PeripheralInfo, - ) - - path = Path(board_yaml_path) - data = yaml.safe_load(path.read_text()) - board = data.get("board", data) - - profile = HardwareProfile( - mcu=board.get("mcu", ""), - mcu_family=board.get("family", ""), - arch=board.get("arch", ""), - core=board.get("core", ""), - vendor=board.get("vendor", ""), - clock_hz=board.get("clock_hz", 0), - flash_size=board.get("memory", {}).get("flash", 0), - ram_size=board.get("memory", {}).get("ram", 0), - features=board.get("features", []), - ) - - for p in board.get("peripherals", []): - profile.peripherals.append(PeripheralInfo( - name=p.get("name", ""), - peripheral_type=p.get("type", ""), - bus=p.get("bus", ""), - )) - - return profile - - -@cli.command("new") -@click.argument("project_name") -@click.option( - "--template", "template_name", - default="bare-metal", - type=click.Choice(["bare-metal", "ble-sensor", "rtos-app", "linux-app", "secure-boot", "safety-critical"]), - help="Project template to use.", -) -@click.option( - "--board", "board_name", - default="generic", - help="Target board name (e.g., nrf52, stm32h7, rpi4, generic).", -) -@click.option( - "--output-dir", - default=None, - type=click.Path(), - help="Parent directory for the new project. Defaults to current directory.", -) -@click.pass_obj -def new(log: Logger, project_name: str, template_name: str, board_name: str, - output_dir: Optional[str]) -> None: - """Scaffold a new EoS project from a template. - - Creates a ready-to-build project directory with src/main.c, build.yaml, - eos.yaml, and README.md pre-configured for the selected template and board. - - Examples: - - ebuild new my-sensor --template ble-sensor --board nrf52 - - ebuild new my-controller --template rtos-app --board stm32h7 - - ebuild new my-app --template bare-metal - - ebuild new my-gateway --template linux-app --board rpi4 - """ - log.header("ebuild — New Project") - - # Resolve template directory - templates_dir = Path(__file__).resolve().parent.parent.parent / "templates" - template_dir = templates_dir / template_name - - if not template_dir.is_dir(): - log.error(f"Template '{template_name}' not found at {templates_dir}") - log.info(f"Available templates: {', '.join(t.name for t in templates_dir.iterdir() if t.is_dir())}") - raise SystemExit(1) - - # Resolve output directory - parent = Path(output_dir) if output_dir else Path(".") - project_dir = parent / project_name - - if project_dir.exists(): - log.error(f"Directory already exists: {project_dir}") - raise SystemExit(1) - - # Board → arch/toolchain mapping - board_map = { - "nrf52": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "nrf52840": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "stm32h7": {"arch": "arm", "core": "cortex-m7", "toolchain": "arm-none-eabi", "vendor": "st"}, - "stm32f4": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "st"}, - "rpi4": {"arch": "arm64", "core": "cortex-a72", "toolchain": "aarch64-linux-gnu", "vendor": "broadcom"}, - "esp32": {"arch": "xtensa", "core": "lx6", "toolchain": "xtensa-esp32-elf", "vendor": "espressif"}, - "rp2040": {"arch": "arm", "core": "cortex-m0+", "toolchain": "arm-none-eabi", "vendor": "raspberrypi"}, - "tms570": {"arch": "arm", "core": "cortex-r5f", "toolchain": "arm-none-eabi", "vendor": "ti"}, - "am64x": {"arch": "hybrid", "core": "cortex-a53+r5f", "toolchain": "aarch64-linux-gnu", "vendor": "ti"}, - "generic": {"arch": "host", "core": "host", "toolchain": "host", "vendor": "generic"}, - } - board_info = board_map.get(board_name, board_map["generic"]) - - log.step(f"Creating project '{project_name}' from '{template_name}' template...") - log.info(f"Board: {board_name} (arch={board_info['arch']}, core={board_info['core']})") - - # Create project directory structure - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Template variable substitution - replacements = { - "{{PROJECT_NAME}}": project_name, - "{{BOARD_NAME}}": board_name, - "{{ARCH}}": board_info["arch"], - "{{CORE}}": board_info["core"], - "{{TOOLCHAIN}}": board_info["toolchain"], - "{{VENDOR}}": board_info["vendor"], - "{{TEMPLATE}}": template_name, - } - - # Copy and process template files - file_mapping = { - "main.c.template": src_dir / "main.c", - "build.yaml.template": project_dir / "build.yaml", - "eos.yaml.template": project_dir / "eos.yaml", - "README.md.template": project_dir / "README.md", - } - - for template_file, output_path in file_mapping.items(): - src_path = template_dir / template_file - if not src_path.exists(): - log.warning(f"Template file missing: {template_file}") - continue - - content = src_path.read_text(encoding="utf-8") - for key, val in replacements.items(): - content = content.replace(key, val) - - output_path.write_text(content, encoding="utf-8") - log.success(f" {output_path.relative_to(parent)}") - - log.success(f"\nProject created: {project_dir}") - log.info("\nNext steps:") - log.info(f" cd {project_name}") - log.info(" ebuild build") - - -@cli.command("generate-boot") -@click.argument("boot_yaml", type=click.Path(exists=True)) -@click.option("--output-dir", default="_generated", help="Output directory.") -@click.pass_obj -def generate_boot(log: Logger, boot_yaml: str, output_dir: str) -> None: - """Generate eboot C headers, linker scripts, and pack scripts from boot.yaml.""" - log.header("ebuild — eboot Config Generation") - - try: - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - from ebuild.eos_ai.eos_validator import EosConfigValidator - - log.step(f"Validating {boot_yaml}...") - validator = EosConfigValidator() - result = validator.validate_boot(boot_yaml) - log.info(result.summary()) - - if not result.valid: - log.error("Boot config validation failed. Fix errors before generating.") - raise SystemExit(1) - - log.step("Generating eboot build inputs...") - integrator = EosBootIntegrator(output_dir) - outputs = integrator.generate_from_boot_yaml(boot_yaml) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("eboot configs generated successfully.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Generation failed: {e}") - raise SystemExit(1) - - -# ═══════════════════════════════════════════════════════════════ -# Dependency management commands -# ═══════════════════════════════════════════════════════════════ - -@cli.command() -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default).") -@click.option("--eos-branch", default=None, help="Branch/tag for eos repo.") -@click.option("--eboot-branch", default=None, help="Branch/tag for eboot repo.") -@click.option("--eos-path", default=None, type=click.Path(exists=True), help="Link to local eos repo (no clone).") -@click.option("--eboot-path", default=None, type=click.Path(exists=True), help="Link to local eboot repo (no clone).") -@click.pass_obj -def setup( - log: Logger, - eos_url: Optional[str], - eboot_url: Optional[str], - eos_branch: Optional[str], - eboot_branch: Optional[str], - eos_path: Optional[str], - eboot_path: Optional[str], -) -> None: - """Clone eos + eboot repos to the local cache (~/.ebuild/repos/). - - On first run this clones both repos with default settings. - Use flags to override URLs, branches, or link to local repos. - - Examples: - - ebuild setup - - ebuild setup --eos-url https://github.com/myfork/eos.git - - ebuild setup --eboot-branch v0.2.0 - - ebuild setup --eos-path /path/to/local/eos - """ - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Setup") - mgr = DepsManager() - - try: - log.step("Setting up eos...") - eos_dir = mgr.setup("eos", url=eos_url, branch=eos_branch, path=eos_path) - log.success(f" eos: {eos_dir}") - - log.step("Setting up eboot...") - eboot_dir = mgr.setup("eboot", url=eboot_url, branch=eboot_branch, path=eboot_path) - log.success(f" eboot: {eboot_dir}") - - log.success("Setup complete. Repos are ready.") - except Exception as e: - log.error(f"Setup failed: {e}") - raise SystemExit(1) - - -@cli.group() -@click.pass_context -def repos(ctx: click.Context) -> None: - """Manage cached eos/eboot repositories.""" - pass - - -@repos.command("status") -@click.pass_obj -def repos_status(log: Logger) -> None: - """Show all repos, URLs, branches, and paths.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Status") - mgr = DepsManager() - entries = mgr.status() - - for info in entries: - log.step(f"{info['name']}") - log.info(f" URL: {info['url']}") - log.info(f" Branch: {info['branch']}") - if info.get("config_path"): - log.info(f" Linked: {info['config_path']}") - if info.get("cached"): - log.info(f" Cached: {info['cache_location']}") - log.info(f" Git: {info.get('git_branch', '?')} @ {info.get('git_commit', '?')}") - else: - log.info(" Cached: no") - - -@repos.command("update") -@click.argument("repo_name", required=False, default=None) -@click.pass_obj -def repos_update(log: Logger, repo_name: Optional[str]) -> None: - """Git pull latest for one or all repos.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Update") - mgr = DepsManager() - results = mgr.update(repo_name) - - for name, result in results.items(): - if "updated" in result: - log.success(f" {name}: {result}") - elif "failed" in result: - log.error(f" {name}: {result}") - else: - log.info(f" {name}: {result}") - - -@repos.command("set-url") -@click.argument("repo_name") -@click.argument("url") -@click.pass_obj -def repos_set_url(log: Logger, repo_name: str, url: str) -> None: - """Change the git URL for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_url(repo_name, url) - log.success(f"Set {repo_name} URL to {url}") - - -@repos.command("set-branch") -@click.argument("repo_name") -@click.argument("branch") -@click.pass_obj -def repos_set_branch(log: Logger, repo_name: str, branch: str) -> None: - """Change the branch/tag for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_branch(repo_name, branch) - log.success(f"Set {repo_name} branch to {branch}") - - -@repos.command("link") -@click.argument("repo_name") -@click.argument("local_path", type=click.Path(exists=True)) -@click.pass_obj -def repos_link(log: Logger, repo_name: str, local_path: str) -> None: - """Link a repo to a local directory (no clone).""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.link(repo_name, local_path) - log.success(f"Linked {repo_name} → {Path(local_path).resolve()}") - - -@repos.command("unlink") -@click.argument("repo_name") -@click.pass_obj -def repos_unlink(log: Logger, repo_name: str) -> None: - """Remove local path override, reverting to cache.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.unlink(repo_name) - log.success(f"Unlinked {repo_name} — will use cached clone.") - - -# ═══════════════════════════════════════════════════════════════ -# Board generation command -# ═══════════════════════════════════════════════════════════════ - -@cli.command("generate-board") -@click.option("--mcu", default=None, help="MCU name (e.g., stm32f407, nrf52840).") -@click.option("--from-kicad", "kicad_file", default=None, type=click.Path(exists=True), help="KiCad schematic (.kicad_sch).") -@click.option("--from-eagle", "eagle_file", default=None, type=click.Path(exists=True), help="Eagle schematic (.sch).") -@click.option("--from-bom", "bom_file", default=None, type=click.Path(exists=True), help="BOM CSV file.") -@click.option("--describe", "description", default=None, help="Text description of hardware.") -@click.option("--product", default=None, help="Product profile for auto-config (e.g., ble-sensor, gateway).") -@click.option("--output", "output_dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_board( - log: Logger, - mcu: Optional[str], - kicad_file: Optional[str], - eagle_file: Optional[str], - bom_file: Optional[str], - description: Optional[str], - product: Optional[str], - output_dir: str, - eos_schemas: Optional[str], -) -> None: - """Generate board/boot/build YAML configs from hardware inputs. - - Accepts an MCU name, KiCad schematic, Eagle schematic, BOM CSV, - or text description. Generates board.yaml, boot.yaml, build.yaml, - eos_product_config.h, and eboot integration files. - - Examples: - - ebuild generate-board --mcu stm32f407 --output ./config/ - - ebuild generate-board --from-kicad design.kicad_sch --output ./config/ - - ebuild generate-board --from-eagle design.sch --output ./config/ - - ebuild generate-board --from-bom parts.csv --output ./config/ - - ebuild generate-board --describe "STM32H743 with CAN, SPI flash" --output ./config/ - - ebuild generate-board --mcu nrf52840 --product ble-sensor --output ./config/ - """ - log.header("ebuild — Board Config Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - # Determine input source - if kicad_file: - log.step(f"Analyzing KiCad schematic: {kicad_file}") - profile = analyzer.interpret_kicad(kicad_file) - elif eagle_file: - log.step(f"Analyzing Eagle schematic: {eagle_file}") - profile = analyzer.interpret_file(eagle_file) - elif bom_file: - log.step(f"Analyzing BOM: {bom_file}") - content = Path(bom_file).read_text(encoding="utf-8", errors="replace") - profile = analyzer.interpret_bom(content) - elif description: - log.step("Analyzing text description...") - profile = analyzer.interpret_text(description) - elif mcu: - log.step(f"Generating config for MCU: {mcu}") - profile = analyzer.interpret_text(mcu) - else: - log.error("Provide --mcu, --from-kicad, --from-eagle, --from-bom, or --describe.") - raise SystemExit(1) - - # Override MCU if explicitly provided alongside another input - if mcu and profile.mcu != mcu: - profile.mcu = mcu - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - log.info(f" - {p.peripheral_type}: {p.name}") - - # Generate configs - log.step("Generating board/boot/build configs...") - gen = EosConfigGenerator(output_dir) - outputs = gen.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - # Validate - log.step("Validating generated configs...") - validator = EosConfigValidator() - val_result = validator.validate_all(output_dir) - log.info(val_result.summary()) - - # Generate eboot integration files - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - log.success("Board config generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Board generation failed: {e}") - raise SystemExit(1) + except (CycleError, ResolveError, FetchError, BuildError) as e: + log.error(f"Error: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.pass_obj +def info(log: Logger, config_path: str) -> None: + """Show project info, targets, packages, and dependency graph.""" + log.header("ebuild — Project Info") + + try: + cfg = load_config(config_path) + + log.info(f"Project : {cfg.name}") + log.info(f"Version : {cfg.version}") + log.info(f"Source : {cfg.source_dir.resolve()}") + + if cfg.toolchain: + tc = cfg.toolchain + log.info(f"Compiler: {tc.compiler} (arch: {tc.arch})") + if tc.prefix: + log.info(f"Prefix : {tc.prefix}") + else: + log.info("Compiler: gcc (native)") + + if cfg.packages: + log.header("Packages") + for p in cfg.packages: + ver = f" v{p.version}" if p.version else "" + log.step(f"{p.name}{ver}") + + log.header("Targets") + for t in cfg.targets: + deps = f" depends=[{', '.join(t.depends)}]" if t.depends else "" + uses = f" uses=[{', '.join(t.uses)}]" if t.uses else "" + log.step(f"{t.name} ({t.target_type}){deps}{uses}") + if t.sources: + log.debug(f" sources: {t.sources}") + if t.cflags: + log.debug(f" cflags : {t.cflags}") + if t.ldflags: + log.debug(f" ldflags: {t.ldflags}") + + graph = build_dependency_graph(cfg.targets) + build_order = graph.topological_sort() + log.header("Build Order") + for i, name in enumerate(build_order, 1): + log.step(f"{i}. {name}") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except ConfigError as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except CycleError as e: + log.error(f"Dependency error: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.pass_obj +def install(log: Logger, config_path: str, build_dir: str) -> None: + """Resolve, fetch, and build all declared packages.""" + log.header("ebuild — Install Packages") + + try: + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + if not cfg.packages: + log.info("No packages declared in build.yaml.") + return + + build_path = Path(build_dir) + _install_packages(cfg, build_path, log, verbose=log.verbose) + log.success("All packages installed successfully.") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + except (ResolveError, FetchError, BuildError) as e: + log.error(f"Package error: {e}") + raise SystemExit(1) + + +def _no_recipe_message(name: str, registry) -> str: + """Say what is available, and what the developer probably meant. + + "No recipe found" on its own leaves them guessing at the spelling, at + whether the package exists under another name, and at where recipes even + come from. + """ + import difflib + + try: + available = sorted({r.name for r in registry.list_packages()}) + except Exception: + available = [] + + lines = [f"No recipe for '{name}'."] + close = difflib.get_close_matches(name, available, n=3, cutoff=0.6) + if close: + lines.append(" Did you mean: " + ", ".join(close) + "?") + if available: + lines.append(" Available: " + ", ".join(available)) + else: + lines.append(" No recipes are visible from here — is this a project " + "directory with a recipes/ folder?") + lines.append(f" To add it anyway: ebuild add {name} --force") + return "\n".join(lines) + + +@cli.command("add") +@click.argument("package_name") +@click.option("--version", "pkg_version", default=None, help="Package version to add.") +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--force", is_flag=True, default=False, + help="Add a package with no recipe. It will not resolve until one exists.", +) +@click.pass_obj +def add_package(log: Logger, package_name: str, pkg_version: Optional[str], + config_path: str, force: bool) -> None: + """Add a package dependency to build.yaml.""" + log.header("ebuild — Add Package") + + config_path_obj = Path(config_path) + if not config_path_obj.exists(): + log.error(f"Config file not found: {config_path}") + raise SystemExit(1) + + # Verify the package exists in registry + recipe_dirs = _find_recipe_dirs(config_path_obj.parent) + if recipe_dirs: + registry = create_registry(*recipe_dirs) + recipe = registry.get(package_name, pkg_version) + if recipe: + log.info(f"Found recipe: {recipe.name} v{recipe.version}") + if pkg_version is None: + pkg_version = recipe.version + elif not force: + # Writing an entry that cannot resolve trades one clear error now + # for a confusing one at build time, in a file the developer has + # since committed. + log.error(_no_recipe_message(package_name, registry)) + raise SystemExit(1) + else: + log.warning( + f"No recipe found for '{package_name}' — added because " + f"--force was given. It will not resolve until a recipe exists." + ) + + # Load and update config + with open(config_path_obj, "r", encoding="utf-8") as f: + raw = yaml.safe_load(f) + + if "packages" not in raw: + raw["packages"] = [] + + # Check for duplicates + for p in raw["packages"]: + if isinstance(p, dict) and p.get("name") == package_name: + log.info(f"Package '{package_name}' already in build.yaml.") + return + + entry: Dict[str, str] = {"name": package_name} + if pkg_version: + entry["version"] = pkg_version + + raw["packages"].append(entry) + + with open(config_path_obj, "w", encoding="utf-8") as f: + yaml.dump(raw, f, default_flow_style=False, sort_keys=False) + + log.success(f"Added {package_name}" + (f" v{pkg_version}" if pkg_version else "") + f" to {config_path}") + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--format", "img_format", + default="tar", + type=click.Choice(["raw", "qcow2", "tar", "ext4", "squashfs"]), + help="Output image format.", +) +@click.option( + "--size", "size_mb", + default=256, + type=int, + help="Image size in MB (for raw/ext4).", +) +@click.pass_obj +def system(log: Logger, config_path: str, build_dir: str, img_format: str, size_mb: int) -> None: + """Build a complete Linux system image (rootfs + kernel + image).""" + log.header("ebuild — System Image Build") + + try: + from ebuild.system.rootfs import RootfsBuilder + from ebuild.system.image import ImageBuilder + + build_path = Path(build_dir) + + log.step("Assembling root filesystem...") + rootfs = RootfsBuilder(build_path) + rootfs_dir = rootfs.assemble(init_system="busybox", hostname="eos") + log.success(f"Rootfs assembled: {rootfs_dir}") + + log.step(f"Creating {img_format} image...") + imager = ImageBuilder(build_path, log=log) + image_path = imager.create( + rootfs_dir=rootfs_dir, + image_format=img_format, + image_size_mb=size_mb, + ) + log.success(f"Image created: {image_path}") + + except Exception as e: + log.error(f"System build failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.option( + "--build-dir", + default="_build", + type=click.Path(), + help="Build output directory.", +) +@click.option( + "--rtos", + default="generic", + type=click.Choice(["zephyr", "freertos", "nuttx", "generic"]), + help="Target RTOS.", +) +@click.option( + "--board", + default="generic", + help="Target board name.", +) +@click.pass_obj +def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: str) -> None: + """Build RTOS firmware for an embedded target.""" + log.header("ebuild — Firmware Build") + + try: + from ebuild.firmware.firmware import FirmwareBuilder + + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + build_path = Path(build_dir) + builder = FirmwareBuilder(build_path, log=log) + + log.step(f"Building {rtos} firmware for {board}...") + output = builder.build( + source_dir=cfg.source_dir, + rtos=rtos, + board=board, + ) + log.success(f"Firmware built: {output}") + + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Firmware build failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.argument("image", type=click.Path(exists=True)) +@click.option("--tool", default="openocd", + type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]), + help="Flash tool to use.") +@click.option("--target", default="stm32f4", help="Target MCU/board.") +@click.option("--address", default="0x08000000", help="Flash base address (hex).") +@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.") +@click.pass_obj +def flash(log: Logger, image: str, tool: str, target: str, address: str, + reset_after: bool) -> None: + """Flash a firmware image to the target device. + + Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash. + + Examples: + + ebuild flash firmware.bin --tool openocd --target stm32f4 + + ebuild flash app.bin --tool nrfjprog + + ebuild flash firmware.bin --tool esptool --address 0x10000 + + ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after + """ + log.header("ebuild — Flash") + + try: + from ebuild.firmware.flash import flash as do_flash, reset as do_reset, FlashError + + image_path = Path(image) + addr = int(address, 0) + + log.step(f"Flashing {image_path.name} to {target} via {tool}...") + log.info(f" Address: {hex(addr)}") + + do_flash(image_path, tool=tool, target=target, address=addr) + log.success(f"Flash complete: {image_path.name}") + + if reset_after: + log.step("Resetting target...") + do_reset(tool=tool, target=target) + log.success("Target reset.") + + except FlashError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Flash failed: {e}") + raise SystemExit(1) + + +@cli.command("list-packages") +@click.option( + "--config", "config_path", + default="build.yaml", + type=click.Path(exists=False), + help="Path to the build configuration file.", +) +@click.pass_obj +def list_packages(log: Logger, config_path: str) -> None: + """List available package recipes and project packages.""" + log.header("ebuild — Package Registry") + + config_path_obj = Path(config_path) + project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") + + recipe_dirs = _find_recipe_dirs(project_dir) + if not recipe_dirs: + log.warning("No recipe directories found.") + return + + registry = create_registry(*recipe_dirs) + packages = registry.list_packages() + + if not packages: + log.info("No recipes found.") + return + + log.info(f"Available recipes ({len(packages)}):") + for recipe in packages: + deps = f" (depends: {', '.join(recipe.dependencies)})" if recipe.dependencies else "" + desc = f" — {recipe.description}" if recipe.description else "" + log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") + + # Show project packages if config exists + if config_path_obj.exists(): + try: + cfg = load_config(config_path_obj) + if cfg.packages: + log.header("Project Packages") + for p in cfg.packages: + ver = f" v{p.version}" if p.version else " (latest)" + status = "✓ recipe found" if registry.has(p.name, p.version) else "✗ no recipe" + log.step(f"{p.name}{ver} — {status}") + except (ConfigError, FileNotFoundError): + pass + + +@cli.command() +@click.argument("input_text", required=False) +@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (KiCad .kicad_sch, Eagle .sch, BOM .csv, YAML, text).") +@click.option("--output-dir", default="_generated", help="Output directory for generated configs.") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.option("--llm", "use_llm", is_flag=True, default=False, help="Enable LLM-enhanced analysis (Ollama local or OPENAI_API_KEY).") +@click.pass_obj +def analyze(log: Logger, input_text: Optional[str], input_file: Optional[str], + output_dir: str, eos_schemas: Optional[str], use_llm: bool) -> None: + """Analyze hardware design and generate eos + eboot + ebuild configs. + + Accepts text description, KiCad schematic (.kicad_sch), Eagle schematic (.sch), + BOM CSV (.csv), or any text/YAML file. Auto-detects format by file extension. + + Generates board.yaml, boot.yaml, build.yaml, and eos_product_config.h. + + Examples: + + ebuild analyze "nRF52840 BLE sensor with I2C and SPI flash" + + ebuild analyze --file design.kicad_sch + + ebuild analyze --file design.sch + + ebuild analyze --file bom.csv + + ebuild analyze "STM32H7 with CAN Ethernet" --llm + """ + log.header("ebuild — Hardware Analysis") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_validator import EosConfigValidator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + + interpreter = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + + if input_file: + path = Path(input_file) + log.step(f"Reading hardware design: {path}") + profile = interpreter.interpret_file(str(path)) + elif input_text: + log.step("Analyzing text description...") + profile = interpreter.interpret_text(input_text) + else: + log.error("Provide hardware description text or --file ") + raise SystemExit(1) + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + for p in profile.peripherals: + extra = "" + if p.config.get("i2c_addr"): + extra = f" (I2C addr: {p.config['i2c_addr']})" + log.info(f" - {p.peripheral_type}: {p.name}{extra}") + log.info(f"Confidence: {profile.confidence:.0%}") + + # Optional LLM-enhanced analysis + if use_llm: + log.step("Running LLM-enhanced analysis...") + llm_info = interpreter.llm_client.get_provider_info() + log.info(f" Provider: {llm_info}") + if interpreter.llm_client.is_available(): + profile = interpreter.analyze_with_llm(profile) + log.success(" LLM analysis complete") + else: + log.warning(" No LLM available. Install Ollama or set OPENAI_API_KEY.") + + log.step("Generating configs...") + generator = EosConfigGenerator(output_dir) + outputs = generator.generate_all(profile) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.step("Validating generated configs...") + validator = EosConfigValidator() + result = validator.validate_all(output_dir) + log.info(result.summary()) + + log.step("Generating eboot integration files...") + integrator = EosBootIntegrator(output_dir) + boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(f" {name}: {path}") + + prompt = interpreter.generate_prompt(profile) + prompt_path = Path(output_dir) / "llm_prompt.txt" + prompt_path.write_text(prompt) + log.info(f"LLM prompt saved: {prompt_path}") + + log.success("Analysis complete.") + + except Exception as e: + log.error(f"Analysis failed: {e}") + raise SystemExit(1) + + +@cli.command("generate-project") +@click.option("--text", "input_text", default=None, help="Hardware description text.") +@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (YAML, KiCad, BOM).") +@click.option("--config", "config_yaml", type=click.Path(exists=True), help="Existing board.yaml from ebuild analyze.") +@click.option("--eos-repo", type=click.Path(exists=True), default=None, help="Path to local eos repo. Auto-clones from GitHub if omitted.") +@click.option("--eboot-repo", type=click.Path(exists=True), default=None, help="Path to local eboot repo. Auto-clones from GitHub if omitted.") +@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default GitHub URL).") +@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default GitHub URL).") +@click.option("--clone-dir", default=None, type=click.Path(), help="Directory to clone repos into. Uses temp dir if omitted.") +@click.option("--output", default="_project", help="Output directory (copy mode).") +@click.option("--mode", type=click.Choice(["copy", "branch"]), default="copy", help="Output mode.") +@click.option("--branch", default=None, help="Git branch name (branch mode only).") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.pass_obj +def generate_project( + log: Logger, + input_text: Optional[str], + input_file: Optional[str], + config_yaml: Optional[str], + eos_repo: Optional[str], + eboot_repo: Optional[str], + eos_url: Optional[str], + eboot_url: Optional[str], + clone_dir: Optional[str], + output: str, + mode: str, + branch: Optional[str], + eos_schemas: Optional[str], +) -> None: + """Generate a stripped-down eos/eboot project for specific hardware. + + Analyzes hardware requirements and prunes the full eos and eboot + repositories to only the modules needed for the target hardware. + Auto-clones eos and eboot from GitHub when local repo paths are not given. + + Examples: + + # Auto-clone from GitHub — no local repos needed: + ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ + --output customer-ble-sensor + + # With local repos: + ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ + --eos-repo ../eos --eboot-repo ../eboot --output customer-ble-sensor + + # From existing hardware analysis: + ebuild generate-project --config _generated/board.yaml \\ + --output gateway-project + + # Custom GitHub fork: + ebuild generate-project --text "STM32H7 industrial controller" \\ + --eos-url https://github.com/myorg/eos.git \\ + --eboot-url https://github.com/myorg/eboot.git \\ + --output industrial-project + + # Branch mode on local repos: + ebuild generate-project --config _generated/board.yaml \\ + --eos-repo ../eos --eboot-repo ../eboot \\ + --mode branch --branch customer/ble-sensor + """ + log.header("ebuild — Project Generator") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_project_generator import EosProjectGenerator + + # Step 1: Obtain a HardwareProfile + if config_yaml: + log.step(f"Loading hardware profile from {config_yaml}...") + profile = _load_profile_from_board_yaml(config_yaml) + elif input_file: + log.step(f"Analyzing hardware design: {input_file}...") + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + path = Path(input_file) + if path.suffix == ".kicad_sch": + profile = analyzer.interpret_kicad(str(path)) + else: + content = path.read_text(encoding="utf-8", errors="replace") + if "," in content and len(content.split("\n")) > 2: + profile = analyzer.interpret_bom(content) + else: + profile = analyzer.interpret_text(content) + elif input_text: + log.step("Analyzing text description...") + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + profile = analyzer.interpret_text(input_text) + else: + log.error("Provide hardware description via --text, --file, or --config.") + raise SystemExit(1) + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + + # Step 2: Create generator and auto-clone repos if needed + generator = EosProjectGenerator( + eos_repo=eos_repo, + eboot_repo=eboot_repo, + eos_url=eos_url, + eboot_url=eboot_url, + ) + + if not eos_repo or not eboot_repo: + log.step("Cloning repos from GitHub (repos not provided locally)...") + generator.ensure_repos( + need_eos=(eos_repo is None), + need_eboot=(eboot_repo is None), + clone_dir=clone_dir, + ) + if generator.eos_repo and not eos_repo: + log.info(f" eos cloned to: {generator.eos_repo}") + if generator.eboot_repo and not eboot_repo: + log.info(f" eboot cloned to: {generator.eboot_repo}") + + manifest = generator.resolve_manifest(profile) + log.info(f"eos modules: {len(manifest.eos_dirs)} dirs, product={manifest.eos_product}") + log.info(f"eboot modules: {len(manifest.eboot_files)} core files, board={manifest.eboot_board}") + if manifest.eos_toolchain: + log.info(f"eos toolchain: {manifest.eos_toolchain}") + if manifest.eos_examples: + log.info(f"eos examples: {', '.join(manifest.eos_examples)}") + log.info(f"eos extras: {', '.join(manifest.eos_extras)}") + log.info(f"eboot extras: {', '.join(manifest.eboot_extras)}") + + log.step(f"Generating project ({mode} mode)...") + outputs = generator.generate( + profile=profile, + output=output, + mode=mode, + branch=branch, + ) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.success("Project generation complete.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Project generation failed: {e}") + raise SystemExit(1) + + +def _load_profile_from_board_yaml(board_yaml_path: str) -> "HardwareProfile": + """Load a HardwareProfile from a board.yaml produced by ``ebuild analyze``.""" + from ebuild.eos_ai.eos_hw_analyzer import ( + HardwareProfile, + PeripheralInfo, + ) + + path = Path(board_yaml_path) + data = yaml.safe_load(path.read_text()) + board = data.get("board", data) + + profile = HardwareProfile( + mcu=board.get("mcu", ""), + mcu_family=board.get("family", ""), + arch=board.get("arch", ""), + core=board.get("core", ""), + vendor=board.get("vendor", ""), + clock_hz=board.get("clock_hz", 0), + flash_size=board.get("memory", {}).get("flash", 0), + ram_size=board.get("memory", {}).get("ram", 0), + features=board.get("features", []), + ) + + for p in board.get("peripherals", []): + profile.peripherals.append(PeripheralInfo( + name=p.get("name", ""), + peripheral_type=p.get("type", ""), + bus=p.get("bus", ""), + )) + + return profile + + +@cli.command("new") +@click.argument("project_name") +@click.option( + "--template", "template_name", + default="bare-metal", + type=click.Choice(["bare-metal", "ble-sensor", "rtos-app", "linux-app", "secure-boot", "safety-critical"]), + help="Project template to use.", +) +@click.option( + "--board", "board_name", + default="generic", + help="Target board name (e.g., nrf52, stm32h7, rpi4, generic).", +) +@click.option( + "--output-dir", + default=None, + type=click.Path(), + help="Parent directory for the new project. Defaults to current directory.", +) +@click.pass_obj +def new(log: Logger, project_name: str, template_name: str, board_name: str, + output_dir: Optional[str]) -> None: + """Scaffold a new EoS project from a template. + + Creates a ready-to-build project directory with src/main.c, build.yaml, + eos.yaml, and README.md pre-configured for the selected template and board. + + Examples: + + ebuild new my-sensor --template ble-sensor --board nrf52 + + ebuild new my-controller --template rtos-app --board stm32h7 + + ebuild new my-app --template bare-metal + + ebuild new my-gateway --template linux-app --board rpi4 + """ + log.header("ebuild — New Project") + + # Resolve template directory + templates_dir = Path(__file__).resolve().parent.parent.parent / "templates" + template_dir = templates_dir / template_name + + if not template_dir.is_dir(): + log.error(f"Template '{template_name}' not found at {templates_dir}") + log.info(f"Available templates: {', '.join(t.name for t in templates_dir.iterdir() if t.is_dir())}") + raise SystemExit(1) + + # Resolve output directory + parent = Path(output_dir) if output_dir else Path(".") + project_dir = parent / project_name + + if project_dir.exists(): + log.error(f"Directory already exists: {project_dir}") + raise SystemExit(1) + + # Board → arch/toolchain mapping + board_map = { + "nrf52": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, + "nrf52840": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, + "stm32h7": {"arch": "arm", "core": "cortex-m7", "toolchain": "arm-none-eabi", "vendor": "st"}, + "stm32f4": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "st"}, + "rpi4": {"arch": "arm64", "core": "cortex-a72", "toolchain": "aarch64-linux-gnu", "vendor": "broadcom"}, + "esp32": {"arch": "xtensa", "core": "lx6", "toolchain": "xtensa-esp32-elf", "vendor": "espressif"}, + "rp2040": {"arch": "arm", "core": "cortex-m0+", "toolchain": "arm-none-eabi", "vendor": "raspberrypi"}, + "tms570": {"arch": "arm", "core": "cortex-r5f", "toolchain": "arm-none-eabi", "vendor": "ti"}, + "am64x": {"arch": "hybrid", "core": "cortex-a53+r5f", "toolchain": "aarch64-linux-gnu", "vendor": "ti"}, + "generic": {"arch": "host", "core": "host", "toolchain": "host", "vendor": "generic"}, + } + board_info = board_map.get(board_name, board_map["generic"]) + + log.step(f"Creating project '{project_name}' from '{template_name}' template...") + log.info(f"Board: {board_name} (arch={board_info['arch']}, core={board_info['core']})") + + # Create project directory structure + src_dir = project_dir / "src" + src_dir.mkdir(parents=True) + + # Template variable substitution + replacements = { + "{{PROJECT_NAME}}": project_name, + "{{BOARD_NAME}}": board_name, + "{{ARCH}}": board_info["arch"], + "{{CORE}}": board_info["core"], + "{{TOOLCHAIN}}": board_info["toolchain"], + "{{VENDOR}}": board_info["vendor"], + "{{TEMPLATE}}": template_name, + } + + # Copy and process template files + file_mapping = { + "main.c.template": src_dir / "main.c", + "build.yaml.template": project_dir / "build.yaml", + "eos.yaml.template": project_dir / "eos.yaml", + "README.md.template": project_dir / "README.md", + } + + for template_file, output_path in file_mapping.items(): + src_path = template_dir / template_file + if not src_path.exists(): + log.warning(f"Template file missing: {template_file}") + continue + + content = src_path.read_text(encoding="utf-8") + for key, val in replacements.items(): + content = content.replace(key, val) + + output_path.write_text(content, encoding="utf-8") + log.success(f" {output_path.relative_to(parent)}") + + log.success(f"\nProject created: {project_dir}") + log.info("\nNext steps:") + log.info(f" cd {project_name}") + log.info(" ebuild build") + + +@cli.command("generate-boot") +@click.argument("boot_yaml", type=click.Path(exists=True)) +@click.option("--output-dir", default="_generated", help="Output directory.") +@click.pass_obj +def generate_boot(log: Logger, boot_yaml: str, output_dir: str) -> None: + """Generate eboot C headers, linker scripts, and pack scripts from boot.yaml.""" + log.header("ebuild — eboot Config Generation") + + try: + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + from ebuild.eos_ai.eos_validator import EosConfigValidator + + log.step(f"Validating {boot_yaml}...") + validator = EosConfigValidator() + result = validator.validate_boot(boot_yaml) + log.info(result.summary()) + + if not result.valid: + log.error("Boot config validation failed. Fix errors before generating.") + raise SystemExit(1) + + log.step("Generating eboot build inputs...") + integrator = EosBootIntegrator(output_dir) + outputs = integrator.generate_from_boot_yaml(boot_yaml) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + log.success("eboot configs generated successfully.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Generation failed: {e}") + raise SystemExit(1) + + +# ═══════════════════════════════════════════════════════════════ +# Dependency management commands +# ═══════════════════════════════════════════════════════════════ + +@cli.command() +@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default).") +@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default).") +@click.option("--eos-branch", default=None, help="Branch/tag for eos repo.") +@click.option("--eboot-branch", default=None, help="Branch/tag for eboot repo.") +@click.option("--eos-path", default=None, type=click.Path(exists=True), help="Link to local eos repo (no clone).") +@click.option("--eboot-path", default=None, type=click.Path(exists=True), help="Link to local eboot repo (no clone).") +@click.pass_obj +def setup( + log: Logger, + eos_url: Optional[str], + eboot_url: Optional[str], + eos_branch: Optional[str], + eboot_branch: Optional[str], + eos_path: Optional[str], + eboot_path: Optional[str], +) -> None: + """Clone eos + eboot repos to the local cache (~/.ebuild/repos/). + + On first run this clones both repos with default settings. + Use flags to override URLs, branches, or link to local repos. + + Examples: + + ebuild setup + + ebuild setup --eos-url https://github.com/myfork/eos.git + + ebuild setup --eboot-branch v0.2.0 + + ebuild setup --eos-path /path/to/local/eos + """ + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Setup") + mgr = DepsManager() + + try: + log.step("Setting up eos...") + eos_dir = mgr.setup("eos", url=eos_url, branch=eos_branch, path=eos_path) + log.success(f" eos: {eos_dir}") + + log.step("Setting up eboot...") + eboot_dir = mgr.setup("eboot", url=eboot_url, branch=eboot_branch, path=eboot_path) + log.success(f" eboot: {eboot_dir}") + + log.success("Setup complete. Repos are ready.") + except Exception as e: + log.error(f"Setup failed: {e}") + raise SystemExit(1) + + +@cli.group() +@click.pass_context +def repos(ctx: click.Context) -> None: + """Manage cached eos/eboot repositories.""" + pass + + +@repos.command("status") +@click.pass_obj +def repos_status(log: Logger) -> None: + """Show all repos, URLs, branches, and paths.""" + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Repo Status") + mgr = DepsManager() + entries = mgr.status() + + for info in entries: + log.step(f"{info['name']}") + log.info(f" URL: {info['url']}") + log.info(f" Branch: {info['branch']}") + if info.get("config_path"): + log.info(f" Linked: {info['config_path']}") + if info.get("cached"): + log.info(f" Cached: {info['cache_location']}") + log.info(f" Git: {info.get('git_branch', '?')} @ {info.get('git_commit', '?')}") + else: + log.info(" Cached: no") + + +@repos.command("update") +@click.argument("repo_name", required=False, default=None) +@click.pass_obj +def repos_update(log: Logger, repo_name: Optional[str]) -> None: + """Git pull latest for one or all repos.""" + from ebuild.deps.manager import DepsManager + + log.header("ebuild — Repo Update") + mgr = DepsManager() + results = mgr.update(repo_name) + + for name, result in results.items(): + if "updated" in result: + log.success(f" {name}: {result}") + elif "failed" in result: + log.error(f" {name}: {result}") + else: + log.info(f" {name}: {result}") + + +@repos.command("set-url") +@click.argument("repo_name") +@click.argument("url") +@click.pass_obj +def repos_set_url(log: Logger, repo_name: str, url: str) -> None: + """Change the git URL for a repo.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.set_url(repo_name, url) + log.success(f"Set {repo_name} URL to {url}") + + +@repos.command("set-branch") +@click.argument("repo_name") +@click.argument("branch") +@click.pass_obj +def repos_set_branch(log: Logger, repo_name: str, branch: str) -> None: + """Change the branch/tag for a repo.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.set_branch(repo_name, branch) + log.success(f"Set {repo_name} branch to {branch}") + + +@repos.command("link") +@click.argument("repo_name") +@click.argument("local_path", type=click.Path(exists=True)) +@click.pass_obj +def repos_link(log: Logger, repo_name: str, local_path: str) -> None: + """Link a repo to a local directory (no clone).""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.link(repo_name, local_path) + log.success(f"Linked {repo_name} → {Path(local_path).resolve()}") + + +@repos.command("unlink") +@click.argument("repo_name") +@click.pass_obj +def repos_unlink(log: Logger, repo_name: str) -> None: + """Remove local path override, reverting to cache.""" + from ebuild.deps.manager import DepsManager + + mgr = DepsManager() + mgr.unlink(repo_name) + log.success(f"Unlinked {repo_name} — will use cached clone.") + + +# ═══════════════════════════════════════════════════════════════ +# Board generation command +# ═══════════════════════════════════════════════════════════════ + +@cli.command("generate-board") +@click.option("--mcu", default=None, help="MCU name (e.g., stm32f407, nrf52840).") +@click.option("--from-kicad", "kicad_file", default=None, type=click.Path(exists=True), help="KiCad schematic (.kicad_sch).") +@click.option("--from-eagle", "eagle_file", default=None, type=click.Path(exists=True), help="Eagle schematic (.sch).") +@click.option("--from-bom", "bom_file", default=None, type=click.Path(exists=True), help="BOM CSV file.") +@click.option("--describe", "description", default=None, help="Text description of hardware.") +@click.option("--product", default=None, help="Product profile for auto-config (e.g., ble-sensor, gateway).") +@click.option("--output", "output_dir", default="_generated", help="Output directory for generated configs.") +@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") +@click.pass_obj +def generate_board( + log: Logger, + mcu: Optional[str], + kicad_file: Optional[str], + eagle_file: Optional[str], + bom_file: Optional[str], + description: Optional[str], + product: Optional[str], + output_dir: str, + eos_schemas: Optional[str], +) -> None: + """Generate board/boot/build YAML configs from hardware inputs. + + Accepts an MCU name, KiCad schematic, Eagle schematic, BOM CSV, + or text description. Generates board.yaml, boot.yaml, build.yaml, + eos_product_config.h, and eboot integration files. + + Examples: + + ebuild generate-board --mcu stm32f407 --output ./config/ + + ebuild generate-board --from-kicad design.kicad_sch --output ./config/ + + ebuild generate-board --from-eagle design.sch --output ./config/ + + ebuild generate-board --from-bom parts.csv --output ./config/ + + ebuild generate-board --describe "STM32H743 with CAN, SPI flash" --output ./config/ + + ebuild generate-board --mcu nrf52840 --product ble-sensor --output ./config/ + """ + log.header("ebuild — Board Config Generator") + + try: + from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer + from ebuild.eos_ai.eos_config_generator import EosConfigGenerator + from ebuild.eos_ai.eos_validator import EosConfigValidator + from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator + + analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) + + # Determine input source + if kicad_file: + log.step(f"Analyzing KiCad schematic: {kicad_file}") + profile = analyzer.interpret_kicad(kicad_file) + elif eagle_file: + log.step(f"Analyzing Eagle schematic: {eagle_file}") + profile = analyzer.interpret_file(eagle_file) + elif bom_file: + log.step(f"Analyzing BOM: {bom_file}") + content = Path(bom_file).read_text(encoding="utf-8", errors="replace") + profile = analyzer.interpret_bom(content) + elif description: + log.step("Analyzing text description...") + profile = analyzer.interpret_text(description) + elif mcu: + log.step(f"Generating config for MCU: {mcu}") + profile = analyzer.interpret_text(mcu) + else: + log.error("Provide --mcu, --from-kicad, --from-eagle, --from-bom, or --describe.") + raise SystemExit(1) + + # Override MCU if explicitly provided alongside another input + if mcu and profile.mcu != mcu: + profile.mcu = mcu + + log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") + log.info(f"Arch: {profile.arch or '(unknown)'}") + log.info(f"Peripherals: {len(profile.peripherals)} detected") + for p in profile.peripherals: + log.info(f" - {p.peripheral_type}: {p.name}") + + # Generate configs + log.step("Generating board/boot/build configs...") + gen = EosConfigGenerator(output_dir) + outputs = gen.generate_all(profile) + + for name, path in outputs.items(): + log.success(f" {name}: {path}") + + # Validate + log.step("Validating generated configs...") + validator = EosConfigValidator() + val_result = validator.validate_all(output_dir) + log.info(val_result.summary()) + + # Generate eboot integration files + log.step("Generating eboot integration files...") + integrator = EosBootIntegrator(output_dir) + boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) + for name, path in boot_outputs.items(): + log.success(f" {name}: {path}") + + log.success("Board config generation complete.") + + except SystemExit: + raise + except Exception as e: + log.error(f"Board generation failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option("--json", "as_json", is_flag=True, + help="Emit the checks as JSON, for CI.") +@click.pass_obj +def doctor(log: Logger, as_json: bool) -> None: + """Diagnose the build environment in one command. + + Reports what is installed, what is missing, and what each missing piece + would cost. Read-only: it names the fix rather than applying it. + + Exits non-zero only for problems that actually stop a build, so a + host-only machine with no cross toolchain still passes. + """ + from ebuild.system.doctor import exit_code, format_report, run_all + + checks = run_all() + + if as_json: + import json as _json + click.echo(_json.dumps( + [{"name": c.name, "status": c.status, + "detail": c.detail, "fix": c.fix} for c in checks], + indent=2, + )) + raise SystemExit(exit_code(checks)) + + log.header("ebuild — Environment") + for line in format_report(checks).splitlines(): + click.echo(line) + raise SystemExit(exit_code(checks)) + + +@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("--output", "output_path", default=None, type=click.Path(), + help="Destination .efw path. Defaults to .efw.") +@click.option("--load", "load_addr", default=None, + help="Load address, e.g. 0x08000000.") +@click.option("--entry", "entry_addr", default=None, + help="Entry address, e.g. 0x08000100.") +@click.pass_obj +def package(log: Logger, config_path: str, build_dir: str, + output_path: Optional[str], load_addr: Optional[str], + entry_addr: Optional[str]) -> None: + """Assemble the built artifact into an eFirmware `.efw` image. + + The step §29's development-to-device flow puts between eBuild and the + device. eFirmware implements the format and ships `efwtool`; this drives + it, so a developer does not have to know the tool exists. + """ + from ebuild.build.firmware_image import ( + FirmwareImageError, find_efwtool, missing_tool_message, pack, verify, + ) + from ebuild.deps import EBUILD_REPOS_DIR + + log.header("ebuild — Package") + + try: + cfg = load_config(config_path) + except FileNotFoundError: + log.error(f"No {config_path} here. Run this from a project directory.") + raise SystemExit(1) + except (ConfigError, RecipeError) as e: + log.error(f"Configuration error: {e}") + raise SystemExit(1) + + binaries = [t for t in cfg.targets if t.target_type == "executable"] + if not binaries: + log.error("No executable target in build.yaml — nothing to package.") + raise SystemExit(1) + + artifact = 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) + + efwtool = find_efwtool(Path(EBUILD_REPOS_DIR)) + if efwtool is None: + log.error(missing_tool_message(Path(EBUILD_REPOS_DIR))) + raise SystemExit(1) + + output = Path(output_path or f"{cfg.name}.efw") + log.step(f"Packing {artifact.name} -> {output}") + try: + pack(efwtool, artifact, output, version=cfg.version or "0.0.0", + load_addr=load_addr, entry_addr=entry_addr) + verdict = verify(efwtool, output) + except FirmwareImageError as exc: + log.error(str(exc)) + raise SystemExit(1) + + log.success(f"{output} ({output.stat().st_size} bytes)") + for line in verdict.splitlines(): + log.info(f" {line}") + log.info("") + log.info(f"Inspect it with: {efwtool} inspect {output}") diff --git a/ebuild/core/config.py b/ebuild/core/config.py index 3695545..3e87f19 100644 --- a/ebuild/core/config.py +++ b/ebuild/core/config.py @@ -281,18 +281,25 @@ def load_config(config_path: str | Path) -> ProjectConfig: # --- packages section (optional, Phase 2) --- packages: List[PackageDep] = [] - raw_packages = raw.get("packages", []) - - if isinstance(raw_packages, list): - for p in raw_packages: - if isinstance(p, dict): - pkg_name = p.get("name", "") - pkg_version = p.get("version") - - if pkg_name: - packages.append( - PackageDep(name=pkg_name, version=pkg_version) - ) + if "packages" in raw: + raw_packages = raw["packages"] + if not isinstance(raw_packages, list): + raise ConfigError( + "'packages' must be a list of package definitions." + ) + for pkg in raw_packages: + if not isinstance(pkg, dict): + raise ConfigError( + "Invalid package definition: expected a YAML mapping, " + f"got {type(pkg).__name__}." + ) + pkg_name = pkg.get("name", "") + pkg_version = pkg.get("version") + if not pkg_name: + raise ConfigError("Package definition must have a 'name' field.") + if pkg_version is not None and not isinstance(pkg_version, str): + pkg_version = str(pkg_version) + packages.append(PackageDep(name=pkg_name, version=pkg_version)) return ProjectConfig( name=project_name, diff --git a/ebuild/deps/__init__.py b/ebuild/deps/__init__.py index 41a272b..c5f7219 100644 --- a/ebuild/deps/__init__.py +++ b/ebuild/deps/__init__.py @@ -1,47 +1,55 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2026 EoS Project - -"""ebuild.deps — Dependency management for eos/eboot repositories. - -Auto-creates the ``~/.ebuild/`` directory structure on first import and -provides the default configuration template for ``config.yaml``. -""" - -from __future__ import annotations - -from pathlib import Path - -# Default location for ebuild's persistent state -EBUILD_HOME = Path.home() / ".ebuild" -EBUILD_REPOS_DIR = EBUILD_HOME / "repos" -EBUILD_CONFIG_PATH = EBUILD_HOME / "config.yaml" - -# Default repo URLs (same as eos_project_generator.py) -DEFAULT_EOS_REPO_URL = "https://github.com/embeddedos-org/eos.git" -DEFAULT_EBOOT_REPO_URL = "https://github.com/embeddedos-org/eBoot.git" - -DEFAULT_CONFIG = { - "repos": { - "eos": { - "url": DEFAULT_EOS_REPO_URL, - "branch": "main", - "path": None, - }, - "eboot": { - "url": DEFAULT_EBOOT_REPO_URL, - "branch": "main", - "path": None, - }, - }, - "cache_dir": str(EBUILD_REPOS_DIR), -} - - -def ensure_ebuild_home() -> Path: - """Create ``~/.ebuild/`` and ``~/.ebuild/repos/`` if they don't exist. - - Returns the ``~/.ebuild/`` path. - """ - EBUILD_HOME.mkdir(parents=True, exist_ok=True) - EBUILD_REPOS_DIR.mkdir(parents=True, exist_ok=True) - return EBUILD_HOME +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""ebuild.deps — Dependency management for eos/eboot repositories. + +Auto-creates the ``~/.ebuild/`` directory structure on first import and +provides the default configuration template for ``config.yaml``. +""" + +from __future__ import annotations + +from pathlib import Path + +# Default location for ebuild's persistent state +EBUILD_HOME = Path.home() / ".ebuild" +EBUILD_REPOS_DIR = EBUILD_HOME / "repos" +EBUILD_CONFIG_PATH = EBUILD_HOME / "config.yaml" + +# Default repo URLs (same as eos_project_generator.py) +DEFAULT_EOS_REPO_URL = "https://github.com/embeddedos-org/eos.git" +DEFAULT_EBOOT_REPO_URL = "https://github.com/embeddedos-org/eBoot.git" +DEFAULT_EFIRMWARE_REPO_URL = "https://github.com/embeddedos-org/eFirmware.git" + +DEFAULT_CONFIG = { + "repos": { + "eos": { + "url": DEFAULT_EOS_REPO_URL, + "branch": "main", + "path": None, + }, + "eboot": { + "url": DEFAULT_EBOOT_REPO_URL, + "branch": "main", + "path": None, + }, + # §29 ends the development-to-device flow at an eFirmware artifact. + # `ebuild package` needs efwtool from this checkout to produce one. + "efirmware": { + "url": DEFAULT_EFIRMWARE_REPO_URL, + "branch": "master", + "path": None, + }, + }, + "cache_dir": str(EBUILD_REPOS_DIR), +} + + +def ensure_ebuild_home() -> Path: + """Create ``~/.ebuild/`` and ``~/.ebuild/repos/`` if they don't exist. + + Returns the ``~/.ebuild/`` path. + """ + EBUILD_HOME.mkdir(parents=True, exist_ok=True) + EBUILD_REPOS_DIR.mkdir(parents=True, exist_ok=True) + return EBUILD_HOME diff --git a/ebuild/deps/manager.py b/ebuild/deps/manager.py index 64a3144..2f357d2 100644 --- a/ebuild/deps/manager.py +++ b/ebuild/deps/manager.py @@ -14,7 +14,7 @@ import subprocess import warnings from pathlib import Path -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple import yaml @@ -30,6 +30,12 @@ # Known repo names KNOWN_REPOS = ("eos", "eboot") +# Sibling directory names. GitHub and local checkouts often use eBoot. +SIBLING_DIR_NAMES: Dict[str, Tuple[str, ...]] = { + "eos": ("eos",), + "eboot": ("eboot", "eBoot"), +} + # Environment variable names for path overrides ENV_PATH_VARS = { "eos": "EBUILD_EOS_PATH", @@ -46,7 +52,7 @@ class DepsManager: 2. Environment variables ``EBUILD_EOS_PATH`` / ``EBUILD_EBOOT_PATH`` 3. ``~/.ebuild/config.yaml`` custom ``path:`` override 4. ``~/.ebuild/repos//`` (cached git clone) - 5. Sibling directory ``..//`` (workspace layout) + 5. Sibling directory ``..//`` (workspace layout; ``eboot`` also tries ``eBoot``) 6. Embedded ``core//`` (legacy fallback — prints deprecation warning) """ @@ -204,11 +210,13 @@ def get_repo_path( if cached.is_dir(): return cached - # 5. Sibling directory + # 5. Sibling directory (try documented aliases; Linux is case-sensitive) if project_dir: - sibling = project_dir.parent / repo_name - if sibling.is_dir(): - return sibling + names = SIBLING_DIR_NAMES.get(repo_name, (repo_name,)) + for name in names: + sibling = project_dir.parent / name + if sibling.is_dir(): + return sibling # 6. Legacy embedded core// (deprecation warning) if project_dir: diff --git a/ebuild/packages/fetcher.py b/ebuild/packages/fetcher.py index 7c81b37..f04e8f9 100644 --- a/ebuild/packages/fetcher.py +++ b/ebuild/packages/fetcher.py @@ -47,6 +47,16 @@ def fetch(self, recipe: PackageRecipe, extract_to: str | Path) -> Path: Raises: FetchError: If download or verification fails. """ + # PackageRecipe.validate() rejects a recipe without a checksum, but + # fetch() is reachable with a hand-built recipe too, so refuse here as + # well rather than falling through to an unverified extract. + if not recipe.checksum: + raise FetchError( + f"Refusing to fetch {recipe.name} v{recipe.version}: the recipe " + f"carries no checksum, so there is nothing to verify the " + f"download against." + ) + archive_path = self._download(recipe) if recipe.checksum: try: @@ -66,10 +76,10 @@ def _download(self, recipe: PackageRecipe) -> Path: """Download the source archive if not already cached.""" if not recipe.url: raise FetchError(f"No URL specified for package {recipe.name}") - if not recipe.url.startswith(("http://", "https://")): + if not recipe.url.startswith("https://"): raise FetchError( f"Invalid URL scheme for {recipe.name}: {recipe.url} " - f"(only http:// and https:// are allowed)" + f"(only https:// is allowed)" ) archive_path = self._archive_path(recipe) diff --git a/ebuild/packages/recipe.py b/ebuild/packages/recipe.py index ab8e5cb..71bf2c2 100644 --- a/ebuild/packages/recipe.py +++ b/ebuild/packages/recipe.py @@ -13,6 +13,8 @@ from pathlib import Path from typing import Any, Dict, List, Optional +import re + import yaml @@ -39,6 +41,9 @@ class PackageRecipe: VALID_BUILD_SYSTEMS = ("cmake", "autoconf", "make", "meson", "custom") + #: A bare SHA-256 digest, with or without the "sha256:" prefix. + _SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-fA-F]{64}$") + @property def slug(self) -> str: """Unique identifier: name-version.""" @@ -52,6 +57,31 @@ def validate(self) -> None: raise RecipeError(f"Package '{self.name}' must have a 'version' field.") if not self.url: raise RecipeError(f"Package '{self.name}' must have a 'url' field.") + + # A recipe with a checksum is a pin: a URL plus the digest of exactly + # what should be at it. The digest is not required here -- a recipe is + # also used to model packages that are never downloaded -- but a + # checksum that is present has to be a real one. "sha256:placeholder" + # parsed fine and then failed every single fetch with a mismatch, which + # is how two shipped recipes stayed unfetchable. PackageFetcher.fetch() + # separately refuses to download anything with no checksum at all. + if self.checksum and not self._SHA256_RE.match(self.checksum): + raise RecipeError( + f"Package '{self.name}': checksum '{self.checksum}' is not a " + f"sha256 digest. Expected 64 hex characters, optionally " + f"prefixed with 'sha256:'. Placeholder values are rejected — " + f"they turn every fetch of this package into a checksum " + f"mismatch." + ) + + # Plaintext HTTP defeats the pin's purpose in the common case where a + # recipe is edited without recomputing the digest, and it leaks what is + # being built. Every shipped recipe already uses https. + if self.url.startswith("http://"): + raise RecipeError( + f"Package '{self.name}': plaintext http:// is not accepted for " + f"'{self.url}'. Use https://." + ) if self.build_system not in self.VALID_BUILD_SYSTEMS: raise RecipeError( f"Package '{self.name}': invalid build system '{self.build_system}'. " diff --git a/ebuild/packages/registry.py b/ebuild/packages/registry.py index d63622a..9682c15 100644 --- a/ebuild/packages/registry.py +++ b/ebuild/packages/registry.py @@ -9,11 +9,64 @@ from __future__ import annotations +import re from pathlib import Path -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from ebuild.packages.recipe import PackageRecipe, RecipeError, load_recipe +# Everything from the first '-' or '+' is a suffix: a pre-release tag +# ("3.6.0-rc1") or build metadata ("1.3.1+patch2"). +_SUFFIX_SPLIT = re.compile(r"[-+]") + +_ComponentKey = Tuple[int, int, str] + + +def _component_key(component: str) -> _ComponentKey: + """Order one dot-separated component of a version string. + + Numeric components compare numerically, so 1.10.0 still sorts above + 1.9.0. Anything else compares as text and ranks below any numeric + component, which keeps the ordering total without inventing a meaning + for identifiers the recipe format does not define. + """ + if component.isdigit(): + return (1, int(component), "") + return (0, 0, component) + + +def version_sort_key(version: str) -> tuple: + """Sort key for a package version string. + + ``PackageRecipe.validate()`` accepts any non-empty version, and real + embedded recipes use more than dotted integers: a leading ``v`` + (``v2.9.3``, littlefs's own tag format), pre-release tags + (``3.6.0-rc1``) and build metadata (``1.3.1+patch2``). Ordering used to + be ``[int(x) for x in version.split('.')]``, which raised ValueError on + every one of them -- and did so from ``get()``, ``list_packages()`` and + ``list_all_versions()``, so a single such recipe anywhere in the + registry took down package lookup for the whole project. + + Ordering rules: + * an optional leading ``v`` or ``V`` is ignored; + * the release part is compared component by component, numerically + where a component is all digits; + * a version carrying a pre-release or build suffix sorts below the + otherwise-equal version without one, so 3.6.0-rc1 < 3.6.0; + * nothing raises -- any string has a place in the order. + """ + text = version.strip() + if text[:1] in ("v", "V"): + text = text[1:] + + parts = _SUFFIX_SPLIT.split(text, maxsplit=1) + release = tuple(_component_key(c) for c in parts[0].split(".")) + + if len(parts) == 1: + return (release, 1, ()) + suffix = tuple(_component_key(c) for c in re.split(r"[.\-+]", parts[1])) + return (release, 0, suffix) + class PackageRegistry: """Registry of available package recipes. @@ -74,8 +127,7 @@ def get(self, name: str, version: Optional[str] = None) -> Optional[PackageRecip if version: return versions.get(version) - latest_version = sorted(versions.keys(), key=lambda v: [int(x) for x in v.split('.')])[-1] - return versions[latest_version] + return versions[max(versions, key=version_sort_key)] def has(self, name: str, version: Optional[str] = None) -> bool: """Check if a recipe exists.""" @@ -86,20 +138,13 @@ def list_packages(self) -> List[PackageRecipe]: result = [] for name in sorted(self._recipes.keys()): versions = self._recipes[name] - latest = sorted(versions.keys(), key=lambda v: [int(x) for x in v.split('.')])[-1] - result.append(versions[latest]) + result.append(versions[max(versions, key=version_sort_key)]) return result def list_all_versions(self, name: str) -> List[PackageRecipe]: """Return all versions of a package.""" versions = self._recipes.get(name, {}) - return [ - versions[v] - for v in sorted( - versions.keys(), - key=lambda v: [int(x) for x in v.split(".")], - ) - ] + return [versions[v] for v in sorted(versions, key=version_sort_key)] @property def package_count(self) -> int: diff --git a/ebuild/system/doctor.py b/ebuild/system/doctor.py new file mode 100644 index 0000000..c405904 --- /dev/null +++ b/ebuild/system/doctor.py @@ -0,0 +1,181 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Environment diagnosis — one command that says why the build will fail. + +The MLP list asks for "one-command environment diagnosis". Without it, a +missing cross toolchain surfaces as a compiler-not-found error partway through +a build, a missing repo cache surfaces as `eos/hal.h: No such file`, and a +missing `size` silently drops the footprint report. Each of those is a +different-looking symptom of the same class of problem, and none of them names +the fix. + +Every check is read-only: this reports on the environment, it does not repair +it. `ebuild setup` fetches the repos; installing a toolchain is the +developer's package manager's job, and guessing which one they use is how a +diagnostic tool starts doing damage. +""" + +from __future__ import annotations + +import platform +import re +import shutil +import subprocess +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +OK, MISSING, WARN = "ok", "missing", "warn" + +#: Cross toolchains, and the boards that need them. Named so the report can +#: say what a missing one costs rather than just that it is absent. +_CROSS_TOOLCHAINS = { + "arm-none-eabi": ["stm32f4", "stm32h7", "nrf52", "nrf52840", "rp2040", "tms570"], + "aarch64-linux-gnu": ["rpi4", "am64x"], + "xtensa-esp32-elf": ["esp32"], +} + +_VERSION = re.compile(r"(\d+\.\d+(?:\.\d+)?)") + + +@dataclass +class Check: + name: str + status: str + detail: str = "" + fix: str = "" + + @property + def ok(self) -> bool: + return self.status == OK + + +def _version_of(exe: str, *args: str) -> str: + """First version-looking string in the tool's own output, or ''.""" + try: + proc = subprocess.run([exe, *(args or ("--version",))], + capture_output=True, text=True, timeout=15) + except (OSError, subprocess.TimeoutExpired): + return "" + m = _VERSION.search((proc.stdout or "") + (proc.stderr or "")) + return m.group(1) if m else "" + + +def _tool_check(name: str, exe: str, fix: str, required: bool = True, + version_args: tuple = ()) -> Check: + path = shutil.which(exe) + if not path: + return Check(name, MISSING if required else WARN, f"{exe} not on PATH", fix) + version = _version_of(path, *version_args) + return Check(name, OK, f"{version} ({path})" if version else path) + + +def host_checks() -> List[Check]: + """Everything needed to build for the host.""" + return [ + Check("python", OK, + f"{platform.python_version()} ({sys.executable})"), + _tool_check("ninja", "ninja", + "install ninja-build, or `pip install ninja`"), + _tool_check("cmake", "cmake", + "install cmake (only needed for CMake-backed projects)", + required=False), + _tool_check("host compiler", "cc", + "install a C compiler (build-essential, base-devel, Xcode CLT)"), + _tool_check("size", "size", + "install binutils — without it builds work but report no " + "flash/RAM footprint", + required=False), + _tool_check("git", "git", + "install git — `ebuild setup` clones the eos and eboot repos"), + ] + + +def toolchain_checks() -> List[Check]: + """Cross toolchains, reported as optional. + + A developer targeting only the host is not missing anything, so these are + warnings. What the report adds is which boards each one unlocks. + """ + out = [] + for prefix, boards in sorted(_CROSS_TOOLCHAINS.items()): + exe = f"{prefix}-gcc" + path = shutil.which(exe) + targets = ", ".join(boards) + if path: + out.append(Check(prefix, OK, + f"{_version_of(path)} ({path})".strip())) + else: + out.append(Check(prefix, WARN, f"not installed — no {targets} builds", + f"install the {prefix} toolchain to target {targets}")) + return out + + +def repo_checks() -> List[Check]: + """The cached eos and eboot checkouts `uses: [eos]` resolves against.""" + from ebuild.deps import EBUILD_REPOS_DIR + + out = [] + for name in ("eos", "eboot"): + root = Path(EBUILD_REPOS_DIR) / name + if not root.is_dir(): + out.append(Check(f"{name} repo", MISSING, + f"not cloned at {root}", "run `ebuild setup`")) + continue + if not (root / ".git").exists(): + out.append(Check(f"{name} repo", WARN, + f"{root} exists but is not a git checkout", + "remove it and run `ebuild setup`")) + continue + branch = "" + try: + proc = subprocess.run(["git", "-C", str(root), "rev-parse", + "--abbrev-ref", "HEAD"], + capture_output=True, text=True, timeout=15) + branch = proc.stdout.strip() + except (OSError, subprocess.TimeoutExpired): + pass + out.append(Check(f"{name} repo", OK, + f"{root}" + (f" ({branch})" if branch else ""))) + return out + + +def run_all() -> List[Check]: + return host_checks() + toolchain_checks() + repo_checks() + + +def format_report(checks: List[Check]) -> str: + """The report body, one line per check, widest name setting the column.""" + marks = {OK: "OK ", MISSING: "MISS", WARN: "warn"} + width = max((len(c.name) for c in checks), default=0) + lines = [f" {marks[c.status]} {c.name.ljust(width)} {c.detail}".rstrip() + for c in checks] + + problems = [c for c in checks if c.status == MISSING] + advisories = [c for c in checks if c.status == WARN and c.fix] + + lines.append("") + if problems: + lines.append(f"{len(problems)} problem(s) will stop a build:") + for c in problems: + lines.append(f" - {c.name}: {c.fix}") + else: + lines.append("No problems. The host build path is ready.") + + if advisories: + lines.append("") + lines.append("Optional, for other targets:") + for c in advisories: + lines.append(f" - {c.fix}") + return "\n".join(lines) + + +def exit_code(checks: List[Check]) -> int: + """Non-zero only for things that actually stop a build. + + A warning must not fail CI: a host-only machine legitimately has no cross + toolchain, and a doctor that always exits 1 stops being consulted. + """ + return 1 if any(c.status == MISSING for c in checks) else 0 diff --git a/recipes/freertos.yaml b/recipes/freertos.yaml index b2c7179..2e9fa3a 100644 --- a/recipes/freertos.yaml +++ b/recipes/freertos.yaml @@ -3,7 +3,7 @@ version: "11.1.0" description: "Real-time operating system kernel for embedded devices" license: MIT url: https://github.com/FreeRTOS/FreeRTOS-Kernel/releases/download/V11.1.0/FreeRTOS-KernelV11.1.0.zip -checksum: sha256:e36e5a2fcef99b83e8adbb8f8d5e4181a42c9ff0b604dc6fa7aad2a2ef0e3140 +checksum: sha256:eebd58aa71a623c9381f25f77b708c0ed14ef995a8913e2460fe9f286bb271eb build: cmake configure_args: - -DFREERTOS_HEAP=4 diff --git a/recipes/littlefs.yaml b/recipes/littlefs.yaml index 1fb5fee..bbbd456 100644 --- a/recipes/littlefs.yaml +++ b/recipes/littlefs.yaml @@ -3,7 +3,7 @@ version: "2.9.3" description: "Little fail-safe filesystem designed for microcontrollers" license: BSD-3-Clause url: https://github.com/littlefs-project/littlefs/archive/refs/tags/v2.9.3.tar.gz -checksum: sha256:placeholder +checksum: sha256:9cf2e7db673ea27d967a54cdafe8f55a7ffe27c63a2070ff7424fadd559cad67 build: make build_args: - CC=$(CROSS_COMPILE)gcc diff --git a/recipes/lwip.yaml b/recipes/lwip.yaml index 0fd9020..106a6a4 100644 --- a/recipes/lwip.yaml +++ b/recipes/lwip.yaml @@ -3,7 +3,7 @@ version: "2.2.0" description: "Lightweight TCP/IP stack for embedded systems" license: BSD-3-Clause url: https://download.savannah.nongnu.org/releases/lwip/lwip-2.2.0.zip -checksum: sha256:placeholder +checksum: sha256:c79255f6cb550eaa07d6e90d859b8c1abe81658115ae8175e74b67ac22c7ed87 build: cmake configure_args: - -DLWIP_DIR=${SOURCE_DIR} diff --git a/recipes/mbedtls.yaml b/recipes/mbedtls.yaml index d1ac1a5..371d401 100644 --- a/recipes/mbedtls.yaml +++ b/recipes/mbedtls.yaml @@ -3,7 +3,7 @@ version: "3.6.0" description: "Lightweight TLS/SSL library for embedded systems" license: Apache-2.0 url: https://github.com/Mbed-TLS/mbedtls/releases/download/v3.6.0/mbedtls-3.6.0.tar.bz2 -checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd73 +checksum: sha256:3ecf94fcfdaacafb757786a01b7538a61750ebd85c4b024f56ff8ba1490fcd38 build: cmake configure_args: - -DENABLE_TESTING=OFF diff --git a/scripts/check_vendor_drift.py b/scripts/check_vendor_drift.py new file mode 100755 index 0000000..29ee9b4 --- /dev/null +++ b/scripts/check_vendor_drift.py @@ -0,0 +1,169 @@ +#!/usr/bin/env python3 +"""Compare the vendored snapshots under core/ against their pinned upstreams. + +core/eos/ and core/eboot/ are snapshots of other repositories in this +organisation, not original source. Fixes merged upstream do not reach them, so +they drift silently: a security fix landed in eos is simply absent here, and +nothing reports it. + +ADR-019 in the eos repository records the decision to replace the snapshots with +real pinned dependencies. Until that lands, this check makes the drift visible. + +Existing drift is grandfathered via ``baseline_drift`` in core/UPSTREAM.yaml so +the guard can be merged without blocking work already in flight. *New* drift +fails the build. + +Usage: + scripts/check_vendor_drift.py # check, exit 1 on new drift + scripts/check_vendor_drift.py --list # also list every drifted file +""" + +from __future__ import annotations + +import argparse +import filecmp +import re +import subprocess +import sys +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +PIN_FILE = REPO_ROOT / "core" / "UPSTREAM.yaml" + + +class Pin: + def __init__(self, path: str, repository: str, revision: str, baseline: int) -> None: + self.path = path + self.repository = repository + self.revision = revision + self.baseline = baseline + + +def read_pins(pin_file: Path) -> list[Pin]: + """Parse core/UPSTREAM.yaml without requiring PyYAML. + + The file is a fixed shape — a list of entries with four scalar fields — so a + line scan is enough and keeps this script dependency-free for CI. + """ + if not pin_file.is_file(): + raise SystemExit(f"error: {pin_file} not found") + + text = pin_file.read_text(encoding="utf-8") + fields = { + "path": re.findall(r"^\s*- path:\s*(\S+)", text, re.M), + "repository": re.findall(r"^\s*repository:\s*(\S+)", text, re.M), + "revision": re.findall(r"^\s*revision:\s*(\S+)", text, re.M), + "baseline_drift": re.findall(r"^\s*baseline_drift:\s*(\d+)", text, re.M), + } + counts = {k: len(v) for k, v in fields.items()} + if len(set(counts.values())) != 1 or counts["path"] == 0: + raise SystemExit( + f"error: {pin_file} is malformed — every entry needs path, repository, " + f"revision and baseline_drift (found {counts})" + ) + + return [ + Pin(p, r, rev, int(b)) + for p, r, rev, b in zip( + fields["path"], fields["repository"], fields["revision"], fields["baseline_drift"] + ) + ] + + +def fetch_upstream(repository: str, revision: str, dest: Path) -> None: + """Check out one upstream revision into dest.""" + run = lambda *a: subprocess.run(a, cwd=dest, check=True, capture_output=True) + subprocess.run(["git", "init", "-q", str(dest)], check=True, capture_output=True) + run("git", "remote", "add", "origin", repository) + try: + run("git", "fetch", "-q", "--depth", "1", "origin", revision) + except subprocess.CalledProcessError: + # Some servers refuse single-commit fetches; fall back to full history. + run("git", "fetch", "-q", "origin") + run("git", "checkout", "-q", revision) + + +def compare(local_root: Path, upstream_root: Path) -> tuple[list[str], int, int]: + """Return (drifted paths, files only here, files only upstream).""" + drifted: list[str] = [] + only_here = 0 + + for local_file in sorted(local_root.rglob("*")): + if not local_file.is_file() or ".git" in local_file.parts: + continue + rel = local_file.relative_to(local_root).as_posix() + upstream_file = upstream_root / rel + if not upstream_file.is_file(): + only_here += 1 + elif not filecmp.cmp(local_file, upstream_file, shallow=False): + drifted.append(rel) + + only_upstream = 0 + for upstream_file in upstream_root.rglob("*"): + if not upstream_file.is_file() or ".git" in upstream_file.parts: + continue + rel = upstream_file.relative_to(upstream_root).as_posix() + if not (local_root / rel).is_file(): + only_upstream += 1 + + return drifted, only_here, only_upstream + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--list", action="store_true", help="list every drifted file") + args = parser.parse_args() + + failed = False + + for pin in read_pins(PIN_FILE): + local_root = REPO_ROOT / pin.path + print(f"── {pin.path} ← {pin.repository} @ {pin.revision[:12]}") + + if not local_root.is_dir(): + print(f" error: {pin.path} does not exist") + failed = True + continue + + with tempfile.TemporaryDirectory() as tmp: + upstream_root = Path(tmp) / "upstream" + upstream_root.mkdir() + try: + fetch_upstream(pin.repository, pin.revision, upstream_root) + except subprocess.CalledProcessError as exc: + stderr = (exc.stderr or b"").decode(errors="replace").strip() + print(f" error: could not fetch {pin.revision[:12]}: {stderr}") + failed = True + continue + + drifted, only_here, only_upstream = compare(local_root, upstream_root) + + if args.list: + for rel in drifted: + print(f" drifted: {rel}") + + print( + f" drifted={len(drifted)} baseline={pin.baseline} " + f"only-here={only_here} only-upstream={only_upstream}" + ) + + if len(drifted) > pin.baseline: + print(f" FAIL: drift grew from {pin.baseline} to {len(drifted)}.") + print(f" Send the change upstream to {pin.repository}, or revert it here.") + print(" Do not raise baseline_drift to make this pass.") + failed = True + elif len(drifted) < pin.baseline: + print( + f" Drift reduced to {len(drifted)}. Lower baseline_drift in " + "core/UPSTREAM.yaml to lock the gain in." + ) + else: + print(" OK: no new drift.") + print() + + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/ebuild/conftest.py b/tests/ebuild/conftest.py index 38ee814..ea72fca 100644 --- a/tests/ebuild/conftest.py +++ b/tests/ebuild/conftest.py @@ -17,8 +17,20 @@ # ── Repo roots (available to all tests via conftest) ───────── # tests/ebuild/conftest.py → parent = tests/ebuild/ → parent = tests/ → parent = repo root EBUILD_ROOT = Path(__file__).resolve().parent.parent.parent -EBOOT_ROOT = EBUILD_ROOT.parent / "eboot" -EOS_ROOT = EBUILD_ROOT.parent / "eos" + + +def _sibling_repo(*names: str) -> Path: + """First existing sibling dir, preferring the first name if none exist.""" + parent = EBUILD_ROOT.parent + for name in names: + candidate = parent / name + if candidate.is_dir(): + return candidate + return parent / names[0] + + +EBOOT_ROOT = _sibling_repo("eboot", "eBoot") +EOS_ROOT = _sibling_repo("eos") def _module_available(name: str) -> bool: diff --git a/tests/ebuild/test_config_validation.py b/tests/ebuild/test_config_validation.py index a8b1d7f..2d338bd 100644 --- a/tests/ebuild/test_config_validation.py +++ b/tests/ebuild/test_config_validation.py @@ -96,3 +96,69 @@ def test_toolchain_mapping_is_parsed(tmp_path): assert config.toolchain.sysroot == "/opt/arm-none-eabi" assert config.toolchain.extra_cflags == ["-mcpu=cortex-m4"] assert config.toolchain.extra_ldflags == ["--specs=nosys.specs"] + + +@pytest.mark.parametrize("invalid_packages", [ + {"name": "zlib", "version": "1.2.13"}, + "zlib", + 42, +]) +def test_packages_must_be_a_list(tmp_path, invalid_packages): + path = write_config( + tmp_path, + {"project": {"name": "demo"}, "packages": invalid_packages}, + ) + + with pytest.raises(ConfigError, match="'packages' must be a list"): + load_config(path) + + +@pytest.mark.parametrize("invalid_item", ["zlib", 42, None]) +def test_package_definition_must_be_mapping(tmp_path, invalid_item): + path = write_config( + tmp_path, + {"project": {"name": "demo"}, "packages": [invalid_item]}, + ) + + with pytest.raises(ConfigError, match="expected a YAML mapping"): + load_config(path) + + +def test_package_definition_requires_name(tmp_path): + path = write_config( + tmp_path, + { + "project": {"name": "demo"}, + "packages": [{"version": "1.2.13"}], + }, + ) + + with pytest.raises(ConfigError, match="must have a 'name'"): + load_config(path) + + +def test_packages_list_is_parsed(tmp_path): + path = write_config( + tmp_path, + { + "project": {"name": "demo"}, + "packages": [ + {"name": "zlib", "version": "1.2.13"}, + {"name": "mbedtls"}, + ], + }, + ) + + config = load_config(path) + + assert len(config.packages) == 2 + assert config.packages[0].name == "zlib" + assert config.packages[0].version == "1.2.13" + assert config.packages[1].name == "mbedtls" + assert config.packages[1].version is None + + +def test_omitted_packages_is_empty(tmp_path): + path = write_config(tmp_path, {"project": {"name": "demo"}}) + config = load_config(path) + assert config.packages == [] diff --git a/tests/ebuild/test_deps_manager.py b/tests/ebuild/test_deps_manager.py new file mode 100644 index 0000000..16a186b --- /dev/null +++ b/tests/ebuild/test_deps_manager.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Host tests for DepsManager sibling path resolution.""" + +from pathlib import Path + +import pytest + +from ebuild.deps.manager import DepsManager, SIBLING_DIR_NAMES + + +@pytest.fixture +def isolated_deps(tmp_path, monkeypatch): + """Keep get_repo_path off the real ~/.ebuild cache and config.""" + home = tmp_path / "ebuild-home" + repos = home / "repos" + config = home / "config.yaml" + monkeypatch.setattr("ebuild.deps.manager.ensure_ebuild_home", lambda: home) + monkeypatch.setattr("ebuild.deps.manager.EBUILD_CONFIG_PATH", config) + monkeypatch.setenv("EBUILD_REPOS_DIR", str(repos)) + monkeypatch.delenv("EBUILD_EOS_PATH", raising=False) + monkeypatch.delenv("EBUILD_EBOOT_PATH", raising=False) + home.mkdir() + repos.mkdir() + return tmp_path + + +def _case_sensitive_is_dir(self: Path) -> bool: + """Treat directory names as case-sensitive, as Linux does.""" + try: + names = {child.name for child in self.parent.iterdir()} + except OSError: + return False + return self.name in names + + +def test_eboot_aliases_include_github_casing(): + assert SIBLING_DIR_NAMES["eboot"] == ("eboot", "eBoot") + + +def test_sibling_eboot_resolves_camel_case(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + camel = workspace / "eBoot" + project.mkdir(parents=True) + camel.mkdir() + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is not None + assert resolved.name == "eBoot" + + +def test_sibling_eboot_still_resolves_lowercase(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + lower = workspace / "eboot" + project.mkdir(parents=True) + lower.mkdir() + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is not None + assert resolved.name == "eboot" + + +def test_sibling_eboot_missing_returns_none(isolated_deps, monkeypatch): + workspace = isolated_deps / "ws" + project = workspace / "ebuild" + project.mkdir(parents=True) + monkeypatch.setattr(Path, "is_dir", _case_sensitive_is_dir) + + resolved = DepsManager().get_repo_path("eboot", project_dir=project) + + assert resolved is None diff --git a/tests/ebuild/test_integration_initramfs_security.py b/tests/ebuild/test_integration_initramfs_security.py index 8f4107b..b7caef4 100644 --- a/tests/ebuild/test_integration_initramfs_security.py +++ b/tests/ebuild/test_integration_initramfs_security.py @@ -22,11 +22,25 @@ """ import gzip +import shutil import subprocess +import pytest + from ebuild.cli.integration import _create_initramfs +# _create_initramfs() drives find(1) and cpio(1) directly. Neither exists on a +# stock Windows runner, so these fail with WinError 2 before reaching anything +# they mean to test. Building a Linux initramfs is not a Windows operation; +# skipping is the honest outcome, matching how test_ninja_backend.py skips when +# no host C compiler is present. +requires_cpio = pytest.mark.skipif( + shutil.which("cpio") is None or shutil.which("find") is None, + reason="find(1)/cpio(1) not available on this host", +) + +@requires_cpio def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): """Functional regression: the pipeline must still work correctly.""" rootfs = tmp_path / "rootfs" @@ -56,6 +70,7 @@ def test_create_initramfs_produces_valid_gzip_with_expected_content(tmp_path): assert b"hello.txt" in result.stdout +@requires_cpio def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted(tmp_path): """A build_dir name containing shell syntax must be treated as a plain literal path component, never parsed as shell syntax. Pre-fix, a name @@ -84,6 +99,7 @@ def test_create_initramfs_build_dir_with_shell_metacharacters_is_not_interpreted assert not (rootfs / "pwned_marker").exists() +@requires_cpio def test_create_initramfs_rootfs_with_shell_metacharacters_is_not_interpreted(tmp_path): """Same check for the ``rootfs`` argument (the ``cd {rootfs}`` half of the old shell string).""" diff --git a/tests/ebuild/test_ninja_backend.py b/tests/ebuild/test_ninja_backend.py index cca5812..cdbe90f 100644 --- a/tests/ebuild/test_ninja_backend.py +++ b/tests/ebuild/test_ninja_backend.py @@ -27,7 +27,15 @@ def _shared_library_config(tmp_path, target_cflags=None): ) -def test_shared_library_uses_shared_link_rule(tmp_path): +def test_shared_library_links_with_the_platform_shared_flag(tmp_path): + """A shared_library must link through the compiler driver with the + platform's shared-object flag. + + An earlier revision emitted a dedicated `link_shared` rule hardcoding + `-shared`, which is wrong on macOS (it needs `-dynamiclib`) and skipped the + -L/-l wiring, so the rule was dropped in favour of the generic `link` rule + with the flag pushed into ldflags. + """ config = ProjectConfig( name="shared-example", version="1.0.0", @@ -45,9 +53,15 @@ def test_shared_library_uses_shared_link_rule(tmp_path): NinjaBackend(config, tmp_path / "build", toolchain).generate() ninja_file = (tmp_path / "build" / "build.ninja").read_text(encoding="utf-8") - assert "rule link_shared\n command = $cc -shared" in ninja_file - assert "build " in ninja_file - assert ": link_shared " in ninja_file + shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared" + + lib_line = next( + line for line in ninja_file.splitlines() + if line.startswith("build ") and "libexample" in line + ) + assert ": link " in lib_line + assert f"ldflags = {shared_flag}" in ninja_file + assert "link_shared" not in ninja_file def test_cc_rule_emits_and_consumes_a_depfile(tmp_path): diff --git a/tests/ebuild/test_package_fetcher.py b/tests/ebuild/test_package_fetcher.py index cd69f9a..ea7b82d 100644 --- a/tests/ebuild/test_package_fetcher.py +++ b/tests/ebuild/test_package_fetcher.py @@ -29,11 +29,29 @@ LWIP_230_URL = "https://example.org/lwip/releases/2.3.0/source.tar.gz" -def make_recipe(name, version="2.9.3", url=None, checksum=""): +#: Placeholder digest for tests that never reach checksum verification (bad +#: URL, unsupported format). Real-looking so it passes recipe validation. +DUMMY_SHA256 = "sha256:" + "a" * 64 + + +def make_recipe(name, version="2.9.3", url=None, checksum=None): + """Build a recipe. + + ``checksum`` defaults to the digest of the archive ``fake_download`` + serves for ``url``, so tests about caching and extraction get past + verification. Pass an explicit value to exercise the checksum paths, or + ``""`` to exercise a recipe with no pin at all. + """ + resolved_url = url if url is not None else LITTLEFS_URL + if checksum is None: + try: + checksum = "sha256:" + sha256_of(targz_bytes(resolved_url)) + except Exception: + checksum = DUMMY_SHA256 return PackageRecipe( name=name, version=version, - url=url if url is not None else LITTLEFS_URL, + url=resolved_url, checksum=checksum, ) @@ -181,12 +199,29 @@ def test_bare_checksum_without_sha256_prefix_is_accepted(tmp_path, fake_download assert marker_in(tmp_path / "src") == LITTLEFS_URL -def test_empty_checksum_skips_verification(tmp_path, fake_download): +def test_recipe_without_a_checksum_is_refused(tmp_path, fake_download): + """A recipe with no checksum used to be fetched and extracted unverified. + + The URL alone is "whatever that host serves today". Refusing is the only + honest outcome: there is nothing to check the download against. + """ fetcher = PackageFetcher(tmp_path / "dl") - fetcher.fetch(make_recipe("littlefs", checksum=""), tmp_path / "src") + with pytest.raises(FetchError, match="no checksum"): + fetcher.fetch(make_recipe("littlefs", checksum=""), tmp_path / "src") - assert marker_in(tmp_path / "src") == LITTLEFS_URL + # And nothing was downloaded or extracted on the way to that refusal. + assert not (tmp_path / "src").exists() + + +def test_plaintext_http_is_refused(tmp_path, fake_download): + """https only: a pin is worth much less over a transport anyone can rewrite.""" + fetcher = PackageFetcher(tmp_path / "dl") + recipe = make_recipe("littlefs", url="http://example.org/lib.tar.gz", + checksum=DUMMY_SHA256) + + with pytest.raises(FetchError, match="https"): + fetcher.fetch(recipe, tmp_path / "src") # ── Extraction ─────────────────────────────────────────────── @@ -207,7 +242,13 @@ def test_unsupported_archive_format_is_rejected(tmp_path, monkeypatch): lambda url, filename: open(filename, "wb").write(b"not an archive"), ) fetcher = PackageFetcher(tmp_path / "dl") - recipe = make_recipe("littlefs", url="https://example.org/littlefs/v2.9.3.rar") + # Checksum of the bytes the patched urlretrieve writes, so the fetch gets + # past verification and reaches the format check this test is about. + recipe = make_recipe( + "littlefs", + url="https://example.org/littlefs/v2.9.3.rar", + checksum="sha256:6bbf954ab0045bc546f16a6db16c95afef820dccd807348411ea924dabb972e9", + ) with pytest.raises(FetchError, match="Unsupported archive format"): fetcher.fetch(recipe, tmp_path / "src") diff --git a/tests/ebuild/test_package_registry.py b/tests/ebuild/test_package_registry.py index d9fcae7..1c2f962 100644 --- a/tests/ebuild/test_package_registry.py +++ b/tests/ebuild/test_package_registry.py @@ -1,5 +1,7 @@ +import pytest + from ebuild.packages.recipe import PackageRecipe -from ebuild.packages.registry import PackageRegistry +from ebuild.packages.registry import PackageRegistry, version_sort_key def make_recipe(version: str) -> PackageRecipe: @@ -10,6 +12,13 @@ def make_recipe(version: str) -> PackageRecipe: ) +def registry_with(*versions: str) -> PackageRegistry: + registry = PackageRegistry() + for version in versions: + registry._register(make_recipe(version)) + return registry + + def test_list_all_versions_uses_numeric_version_order(): registry = PackageRegistry() @@ -24,3 +33,84 @@ def test_list_all_versions_uses_numeric_version_order(): "1.9.0", "1.10.0", ] + + +# --- Versions that are not dotted integers ----------------------------------- +# +# PackageRecipe.validate() accepts any non-empty version string, so these all +# load and register. Ordering used to be [int(x) for x in v.split('.')], which +# raised ValueError on every one of them. + + +@pytest.mark.parametrize( + "version", + [ + "v2.9.3", # littlefs publishes its releases with a leading v + "3.6.0-rc1", # pre-release tag + "1.3.1+patch2", # build metadata + "2024.06", # date-stamped release + "main", # a branch, not a release + "", # degenerate, but reachable through _register() + ], +) +def test_lookup_survives_a_non_numeric_version(version): + registry = registry_with(version) + + assert registry.get("demo").version == version + assert [r.version for r in registry.list_packages()] == [version] + assert [r.version for r in registry.list_all_versions("demo")] == [version] + + +def test_one_odd_version_does_not_break_lookup_of_the_rest(): + """A single unparseable version used to take down the whole registry. + + get() with no version scans every version of the package, and + list_packages() scans every package -- which the resolver calls to build + its 'package not found' message. One recipe with a 'v' prefix therefore + turned an ordinary lookup anywhere in the project into a ValueError. + """ + registry = registry_with("1.0.0", "v9.9.9", "1.2.0") + + assert registry.get("demo").version == "v9.9.9" + assert registry.get("demo", "1.2.0").version == "1.2.0" + assert len(registry.list_all_versions("demo")) == 3 + + +def test_leading_v_does_not_change_precedence(): + registry = registry_with("v2.9.3", "2.10.0") + + assert registry.get("demo").version == "2.10.0" + + +def test_prerelease_sorts_below_its_release(): + registry = registry_with("3.6.0", "3.6.0-rc1", "3.6.0-rc2") + + assert [r.version for r in registry.list_all_versions("demo")] == [ + "3.6.0-rc1", + "3.6.0-rc2", + "3.6.0", + ] + assert registry.get("demo").version == "3.6.0" + + +def test_build_metadata_does_not_outrank_the_next_release(): + registry = registry_with("1.3.1+patch2", "1.3.2") + + assert registry.get("demo").version == "1.3.2" + + +def test_version_ordering_is_total_and_never_raises(): + """Every pair must be comparable, in both directions, without raising.""" + versions = [ + "1.0.0", "1.0", "1.0.1", "v1.0.1", "2024.06", "1.0.0-rc1", + "1.0.0+meta", "main", "", "1.0.0-alpha.1", "10.0.0", + ] + keys = [version_sort_key(v) for v in versions] + + for left in keys: + for right in keys: + assert (left < right) or (left >= right) + + assert sorted(versions, key=version_sort_key) == sorted( + versions, key=version_sort_key + ) diff --git a/tests/unit/test_add_and_summary.py b/tests/unit/test_add_and_summary.py new file mode 100644 index 0000000..a0e3f15 --- /dev/null +++ b/tests/unit/test_add_and_summary.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild add` refusing what it cannot resolve, and the build summary. + +The MLP walk is: + + ebuild new temperature-monitor + ebuild configure --board + ebuild add wifi + ebuild add mqtt + ebuild build + EmbeddedOS Build OK toolchain OK board configuration OK dependencies ... + +Two gaps against that. `ebuild add` warned about an unknown package and added +it anyway, trading one clear error now for a confusing one at build time in a +file the developer has since committed. And a successful build printed only +"Build completed successfully", leaving them to infer what was in it. +""" + +from types import SimpleNamespace + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.cli.commands import _no_recipe_message, cli + + +class _FakeRegistry: + def __init__(self, names): + self._names = names + + def list_packages(self): + return [SimpleNamespace(name=n) for n in self._names] + + +class TestNoRecipeMessage: + def test_it_lists_what_is_available(self): + """"No recipe found" alone leaves the developer guessing at the + spelling, at whether it exists under another name, and at where + recipes come from.""" + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip", "mbedtls"])) + assert "lwip" in msg and "mbedtls" in msg + + def test_a_near_miss_is_suggested(self): + msg = _no_recipe_message("lwipp", _FakeRegistry(["lwip", "zlib"])) + assert "Did you mean" in msg and "lwip" in msg + + def test_an_unrelated_name_gets_no_suggestion(self): + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip", "zlib"])) + assert "Did you mean" not in msg + + def test_it_names_the_escape_hatch(self): + msg = _no_recipe_message("wifi", _FakeRegistry(["lwip"])) + assert "--force" in msg + + def test_an_empty_registry_says_so_rather_than_listing_nothing(self): + msg = _no_recipe_message("wifi", _FakeRegistry([])) + assert "No recipes are visible" in msg + + +@pytest.fixture +def project(tmp_path): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "p", "version": "0.1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "p", "type": "executable", "sources": ["src/main.c"]}], + }), encoding="utf-8") + recipes = tmp_path / "recipes" + recipes.mkdir() + (recipes / "lwip.yaml").write_text(yaml.safe_dump({ + "name": "lwip", "version": "2.2.0", + "url": "https://example.invalid/lwip-2.2.0.tar.gz", + "checksum": "sha256:" + "0" * 64, + "build": {"type": "cmake"}, + }), encoding="utf-8") + return tmp_path + + +def _packages(path): + return yaml.safe_load((path / "build.yaml").read_text()).get("packages") or [] + + +class TestAddRefusesWhatItCannotResolve: + def test_a_known_package_is_added(self, project): + r = CliRunner().invoke(cli, ["add", "lwip", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 0 + assert [p["name"] for p in _packages(project)] == ["lwip"] + + def test_an_unknown_package_is_refused(self, project): + r = CliRunner().invoke(cli, ["add", "wifi", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 1 + + def test_a_refused_package_is_not_written(self, project): + """The point of refusing: build.yaml must not end up carrying an + entry that can never resolve.""" + CliRunner().invoke(cli, ["add", "wifi", "--config", + str(project / "build.yaml")]) + assert _packages(project) == [] + + def test_force_adds_it_anyway(self, project): + r = CliRunner().invoke(cli, ["add", "wifi", "--force", "--config", + str(project / "build.yaml")]) + assert r.exit_code == 0 + assert [p["name"] for p in _packages(project)] == ["wifi"] + + def test_adding_the_same_package_twice_is_a_no_op(self, project): + cfg = str(project / "build.yaml") + CliRunner().invoke(cli, ["add", "lwip", "--config", cfg]) + CliRunner().invoke(cli, ["add", "lwip", "--config", cfg]) + assert len(_packages(project)) == 1 + + +class TestBuildSummary: + """Rendered through the real logger, so the assertions are on what a + developer actually sees.""" + + def _render(self, cfg, package_paths): + from ebuild.cli.commands import _build_summary + lines = [] + log = SimpleNamespace( + info=lines.append, + warning=lambda m: lines.append("WARN " + m), + verbose=False, + ) + _build_summary(cfg, SimpleNamespace(cc="arm-none-eabi-gcc"), + package_paths, log) + return "\n".join(lines) + + def _cfg(self, packages=()): + return SimpleNamespace( + packages=[SimpleNamespace(name=n) for n in packages], + targets=[SimpleNamespace(name="app", target_type="executable")], + ) + + def test_it_names_the_toolchain_and_the_application(self): + body = self._render(self._cfg(), {}) + assert "arm-none-eabi-gcc" in body + assert "app" in body + + def test_a_resolved_package_is_ok(self): + paths = {"lwip": SimpleNamespace(include_dirs=["/x/include"], + lib_dirs=[], libraries=[])} + body = self._render(self._cfg(["lwip"]), paths) + assert "OK lwip" in body + + def test_a_package_that_resolved_to_nothing_is_flagged(self): + """The interesting case: the build succeeds, the feature is simply + absent, and nothing said so.""" + paths = {"lwip": SimpleNamespace(include_dirs=[], lib_dirs=[], + libraries=[])} + body = self._render(self._cfg(["lwip"]), paths) + assert "MISS lwip" in body + assert "resolved to nothing" in body + + def test_a_package_missing_from_the_map_entirely_is_flagged(self): + body = self._render(self._cfg(["mqtt"]), {}) + assert "MISS mqtt" in body + + def test_no_warning_when_everything_resolved(self): + paths = {"lwip": SimpleNamespace(include_dirs=["/x"], lib_dirs=[], + libraries=[])} + assert "WARN" not in self._render(self._cfg(["lwip"]), paths) + + def test_names_are_column_aligned(self): + body = self._render(self._cfg(["a-very-long-package-name"]), {}) + rows = [l for l in body.splitlines() if l.startswith(" OK") or l.startswith(" MISS")] + assert len({len(l.split()[1]) for l in rows}) >= 1 # renders without error + assert all(l.startswith(" ") for l in rows) diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py new file mode 100644 index 0000000..241071f --- /dev/null +++ b/tests/unit/test_doctor.py @@ -0,0 +1,141 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild doctor` — environment diagnosis. + +The MLP list asks for "one-command environment diagnosis". Without it, the +same class of problem surfaces three different ways: a missing cross toolchain +as a compiler-not-found partway through a build, a missing repo cache as +`eos/hal.h: No such file`, a missing `size` as a silently absent footprint. +None of those names the fix. + +The behaviour that matters most is the exit code. A doctor that exits 1 on a +host-only machine — which legitimately has no cross toolchain — stops being +consulted. +""" + +import json + +import pytest +from click.testing import CliRunner + +from ebuild.cli.commands import cli +from ebuild.system import doctor as doc +from ebuild.system.doctor import ( + MISSING, + OK, + WARN, + Check, + exit_code, + format_report, + host_checks, + run_all, + toolchain_checks, +) + + +class TestExitCode: + def test_a_clean_environment_passes(self): + assert exit_code([Check("a", OK), Check("b", OK)]) == 0 + + def test_a_missing_cross_toolchain_does_not_fail(self): + """A host-only machine is not broken. If this returns 1, CI on every + such machine goes red and the command gets ignored.""" + assert exit_code([Check("a", OK), Check("arm-none-eabi", WARN)]) == 0 + + def test_something_that_stops_a_build_fails(self): + assert exit_code([Check("ninja", MISSING)]) == 1 + + def test_no_checks_is_not_a_failure(self): + assert exit_code([]) == 0 + + +class TestReport: + def test_every_check_gets_a_line(self): + checks = [Check("alpha", OK, "1.0"), Check("beta", MISSING, "gone", "fix it")] + body = format_report(checks) + assert "alpha" in body and "beta" in body + + def test_a_missing_check_names_its_fix(self): + """Reporting that something is absent without saying what to do is + the part that makes a diagnostic useless.""" + body = format_report([Check("ninja", MISSING, "not on PATH", + "install ninja-build")]) + assert "install ninja-build" in body + + def test_a_clean_run_says_so_plainly(self): + body = format_report([Check("ninja", OK, "1.11")]) + assert "No problems" in body + + def test_warnings_are_listed_apart_from_problems(self): + """A warning is a capability the developer may not want, not a fault.""" + body = format_report([ + Check("ninja", OK, "1.11"), + Check("arm-none-eabi", WARN, "not installed", + "install it to target stm32f4"), + ]) + assert "No problems" in body + assert "Optional, for other targets" in body + assert "stm32f4" in body + + def test_names_are_column_aligned(self): + body = format_report([Check("a", OK, "x"), Check("looooong", OK, "y")]) + first, second = body.splitlines()[:2] + assert first.index("x") == second.index("y") + + +class TestChecks: + def test_host_checks_cover_the_build_path(self): + names = {c.name for c in host_checks()} + assert {"python", "ninja", "host compiler", "git"} <= names + + def test_python_is_always_ok(self): + """It is running the check.""" + python = next(c for c in host_checks() if c.name == "python") + assert python.status == OK + + def test_a_missing_required_tool_is_a_problem(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", lambda _n: None) + assert any(c.status == MISSING for c in host_checks()) + + def test_size_absent_is_optional_not_a_problem(self, monkeypatch): + """Without binutils the build still works; it just reports no + footprint.""" + monkeypatch.setattr(doc.shutil, "which", + lambda n: None if n == "size" else f"/usr/bin/{n}") + size = next(c for c in host_checks() if c.name == "size") + assert size.status == WARN + + def test_every_cross_toolchain_names_the_boards_it_unlocks(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", lambda _n: None) + for check in toolchain_checks(): + assert check.status == WARN + assert check.fix, f"{check.name} says nothing about what it is for" + + def test_a_present_toolchain_is_ok(self, monkeypatch): + monkeypatch.setattr(doc.shutil, "which", + lambda n: "/opt/bin/" + n) + monkeypatch.setattr(doc, "_version_of", lambda *a, **k: "13.2") + assert all(c.status == OK for c in toolchain_checks()) + + +class TestCommand: + def test_doctor_runs_and_reports(self): + result = CliRunner().invoke(cli, ["doctor"]) + assert result.exit_code in (0, 1) + assert "python" in result.output + + def test_json_output_is_machine_readable(self): + """CI wants the checks, not the formatting.""" + result = CliRunner().invoke(cli, ["doctor", "--json"]) + payload = json.loads(result.output) + assert isinstance(payload, list) + assert {"name", "status", "detail", "fix"} <= set(payload[0]) + + def test_json_and_text_agree_on_the_exit_code(self): + a = CliRunner().invoke(cli, ["doctor"]) + b = CliRunner().invoke(cli, ["doctor", "--json"]) + assert a.exit_code == b.exit_code + + def test_doctor_is_registered(self): + assert "doctor" in cli.commands diff --git a/tests/unit/test_footprint.py b/tests/unit/test_footprint.py new file mode 100644 index 0000000..3ee44ed --- /dev/null +++ b/tests/unit/test_footprint.py @@ -0,0 +1,198 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Flash and RAM footprint reporting. + +The MLP developer walk ends with a build that says how much of the board it +used. Nothing produced those numbers, so a developer had to run `size` by hand +and know which columns to add. + +The accounting has to match `scripts/measure_footprint.py` in the eos repo, or +the two tools disagree about what "flash" means: + + flash = text + data + ram = data + bss +""" + +import subprocess +from pathlib import Path + +import pytest + +from ebuild.build.footprint import ( + Footprint, + FootprintError, + board_capacity, + find_size_tool, + format_report, + format_size, + measure, + over_budget, +) + + +class TestAccounting: + """`data` is charged to both regions: it is stored in flash and copied to + RAM at startup. Reading `size`'s "dec" column instead understates RAM.""" + + def test_flash_is_text_plus_data(self): + assert Footprint(text=1000, data=200, bss=500).flash == 1200 + + def test_ram_is_data_plus_bss(self): + assert Footprint(text=1000, data=200, bss=500).ram == 700 + + def test_data_is_counted_in_both(self): + fp = Footprint(text=0, data=64, bss=0) + assert fp.flash == 64 + assert fp.ram == 64 + + def test_a_pure_bss_buffer_costs_ram_but_not_flash(self): + """A zero-initialised array is not stored in the image.""" + fp = Footprint(text=100, data=0, bss=8192) + assert fp.flash == 100 + assert fp.ram == 8192 + + +class TestMeasure: + def test_measures_a_real_binary(self, tmp_path): + src = tmp_path / "m.c" + src.write_text("static char buf[4096];\nint main(void){return buf[0];}\n") + exe = tmp_path / "m" + subprocess.run(["gcc", str(src), "-o", str(exe)], check=True) + + fp = measure(exe) + assert fp.text > 0 + # The 4 KB buffer is zero-initialised, so it lands in bss and shows up + # in RAM without inflating flash. + assert fp.bss >= 4096 + assert fp.ram >= 4096 + + def test_a_missing_artifact_raises_rather_than_reporting_zero(self, tmp_path): + """Reporting "Flash: 0 KB" when nothing was measured is worse than + saying nothing.""" + with pytest.raises(FootprintError, match="no artifact"): + measure(tmp_path / "nope") + + def test_a_missing_size_tool_raises(self, tmp_path, monkeypatch): + exe = tmp_path / "x" + exe.write_bytes(b"\x7fELF") + monkeypatch.setattr("ebuild.build.footprint.shutil.which", lambda _n: None) + with pytest.raises(FootprintError, match="no 'size' tool"): + measure(exe) + + def test_a_file_that_is_not_an_object_raises(self, tmp_path): + junk = tmp_path / "notelf.txt" + junk.write_text("this is not an object file") + with pytest.raises(FootprintError): + measure(junk) + + +class TestSizeToolSelection: + def test_host_build_uses_the_host_tool(self): + assert find_size_tool("host") == find_size_tool(None) + + def test_cross_build_wants_the_toolchain_tool(self, monkeypatch): + seen = {} + + def fake_which(name): + seen["name"] = name + return "/opt/arm/bin/arm-none-eabi-size" + + monkeypatch.setattr("ebuild.build.footprint.shutil.which", fake_which) + assert find_size_tool("arm-none-eabi").endswith("arm-none-eabi-size") + assert seen["name"] == "arm-none-eabi-size" + + def test_cross_build_does_not_fall_back_to_the_host_tool(self, monkeypatch): + """Host `size` on an ARM ELF would report numbers for a different + target, and nothing in the output would say so.""" + monkeypatch.setattr( + "ebuild.build.footprint.shutil.which", + lambda name: None if name.startswith("arm-") else "/usr/bin/size", + ) + assert find_size_tool("arm-none-eabi") is None + + +class TestBoardCapacity: + def test_a_known_family_has_a_reference_part(self): + flash, ram = board_capacity("stm32f4") + assert flash == 1024 * 1024 + assert ram == 192 * 1024 + + def test_lookup_is_case_insensitive(self): + assert board_capacity("STM32F4") == board_capacity("stm32f4") + + def test_a_linux_class_board_has_no_fixed_budget(self): + """A made-up ceiling is worse than none: a percentage reads as + authoritative.""" + assert board_capacity("rpi4") == (None, None) + + def test_an_unknown_board_is_unknown(self): + assert board_capacity("some-new-board") == (None, None) + + def test_project_board_yaml_overrides_the_reference_table(self): + """An STM32F401 has 256 KB of flash where the F407 has 1 MB; a project + that says so should be measured against its own part.""" + cfg = {"memory": {"flash_size": "0x40000", "ram_size": 65536}} + assert board_capacity("stm32f4", cfg) == (262144, 65536) + + def test_hex_and_int_sizes_both_parse(self): + assert board_capacity(None, {"memory": {"flash_size": "0x100"}})[0] == 256 + assert board_capacity(None, {"memory": {"flash_size": 256}})[0] == 256 + + def test_a_zero_or_unparseable_size_is_not_a_capacity(self): + """`flash: 0 (boots from SD card)` appears in the shipped board + descriptions; zero is not a ceiling.""" + assert board_capacity(None, {"memory": {"flash_size": 0}}) == (None, None) + assert board_capacity(None, {"memory": {"flash_size": "lots"}}) == (None, None) + + def test_a_board_config_without_memory_falls_back(self): + assert board_capacity("stm32f4", {"board_name": "x"})[0] == 1024 * 1024 + + +class TestReport: + def test_percentages_appear_only_with_a_known_capacity(self): + fp = Footprint(text=1000, data=100, bss=200) + assert "%" in format_report(fp, 4096, 4096) + assert "%" not in format_report(fp) + + def test_report_names_both_regions(self): + body = format_report(Footprint(text=1, data=1, bss=1)) + assert "Flash" in body and "RAM" in body + + def test_a_known_flash_and_unknown_ram_shows_one_percentage(self): + body = format_report(Footprint(text=1000, data=0, bss=99), 4096, None) + flash_line, ram_line = body.splitlines() + assert "%" in flash_line + assert "%" not in ram_line + + +class TestBudget: + def test_within_budget_is_silent(self): + assert over_budget(Footprint(1000, 100, 200), 1 << 20, 1 << 20) is None + + def test_flash_overflow_is_named(self): + msg = over_budget(Footprint(2 << 20, 0, 0), 1 << 20, 1 << 20) + assert msg and "flash" in msg + + def test_ram_overflow_is_named(self): + msg = over_budget(Footprint(0, 0, 2 << 20), 1 << 20, 1 << 20) + assert msg and "RAM" in msg + + def test_no_capacity_means_no_verdict(self): + """Without a real ceiling there is nothing to be over.""" + assert over_budget(Footprint(1 << 30, 0, 1 << 30)) is None + + def test_exactly_full_is_not_over(self): + assert over_budget(Footprint(1024, 0, 0), 1024, 4096) is None + + +class TestFormatSize: + @pytest.mark.parametrize("n,expected", [ + (0, "0 B"), + (512, "512 B"), + (1024, "1.0 KB"), + (1536, "1.5 KB"), + (1024 * 1024, "1.00 MB"), + ]) + def test_units(self, n, expected): + assert format_size(n) == expected diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e973ca0..51f73f2 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -9,7 +9,7 @@ from pathlib import Path from types import SimpleNamespace -from ebuild.build.ninja_backend import NinjaBackend +from ebuild.build.ninja_backend import NinjaBackend, _ninja_path from ebuild.core.config import ProjectConfig, TargetConfig @@ -66,5 +66,50 @@ def test_static_library_unaffected(self): self.assertNotIn("-dynamiclib", ninja) +class TestNinjaPathEscaping(unittest.TestCase): + """Ninja splits build statements on unescaped spaces and colons. + + A Windows absolute path puts a drive-letter colon into the output field, so + Ninja read the statement as a rule separator and every generated file was + rejected with "expected build command name" -- the backend produced no + usable build on Windows at all. Paths in build statements must be escaped; + variable values must not be, or the flags reach the compiler mangled. + """ + + def test_colons_and_spaces_in_paths_are_escaped(self): + self.assertEqual(_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") + self.assertEqual(_ninja_path("/tmp/my project/main.o"), "/tmp/my$ project/main.o") + + def test_dollar_is_escaped_before_the_escapes_it_introduces(self): + self.assertEqual(_ninja_path("a$b"), "a$$b") + self.assertEqual(_ninja_path("a$b:c"), "a$$b$:c") + + def test_ordinary_posix_paths_are_unchanged(self): + self.assertEqual(_ninja_path("/tmp/build/obj/app/src/main.o"), + "/tmp/build/obj/app/src/main.o") + + +class TestNinjaWindowsStylePaths(unittest.TestCase): + def setUp(self): + self._tmpdir = tempfile.TemporaryDirectory() + self.addCleanup(self._tmpdir.cleanup) + + def test_build_statement_output_is_escaped(self): + build_dir = Path(self._tmpdir.name) / "b" + target = TargetConfig(name="app", target_type="executable", sources=["main.c"]) + config = ProjectConfig(name="proj", version="1.0", targets=[target], + source_dir=build_dir) + NinjaBackend(config, build_dir, _toolchain()).generate() + ninja = (build_dir / "build.ninja").read_text(encoding="utf-8") + + for line in ninja.splitlines(): + if not line.startswith("build "): + continue + # Exactly one unescaped colon per build statement: the one that + # separates outputs from the rule name. + without_escapes = line.replace("$:", "").replace("$$", "") + self.assertEqual(without_escapes.count(":"), 1, line) + + if __name__ == "__main__": unittest.main() diff --git a/tests/unit/test_package_efw.py b/tests/unit/test_package_efw.py new file mode 100644 index 0000000..7d6bc0b --- /dev/null +++ b/tests/unit/test_package_efw.py @@ -0,0 +1,173 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild package` — the step §29 puts between eBuild and the device. + + eBuild -> {EoS, eBoot, application} -> eFirmware artifact -> {EoSim, hardware} + +Every piece of that existed except the arrow into eFirmware. The eFirmware +repository implements the image format and ships `efwtool`; nothing in ebuild +referenced it, so a developer had to know the tool existed, build it, and run +it by hand. + +These cover the wiring, not the format. `efwtool` owns the format and has its +own tests; duplicating them here would mean a second definition of the header +to keep in step, which is the failure this repository has spent the week +repairing. +""" + +import os +import stat + +import pytest +import yaml +from click.testing import CliRunner + +from ebuild.build.firmware_image import ( + FirmwareImageError, + find_efwtool, + missing_tool_message, + pack, + verify, +) +from ebuild.cli.commands import cli + + +def _fake_efwtool(tmp_path, body): + """A stand-in for efwtool, so the wiring is testable without building C.""" + tool = tmp_path / "efwtool" + tool.write_text("#!/bin/sh\n" + body) + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + return tool + + +class TestToolDiscovery: + def test_an_efwtool_on_path_wins(self, tmp_path, monkeypatch): + """A developer with their own build should not have one silently + compiled behind their back.""" + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", + lambda n: "/usr/local/bin/efwtool" if n == "efwtool" else None) + assert str(find_efwtool(tmp_path)).endswith("efwtool") + + def test_no_checkout_and_no_tool_returns_none(self, tmp_path, monkeypatch): + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", lambda n: None) + assert find_efwtool(tmp_path) is None + + def test_a_prebuilt_tool_in_the_cache_is_reused(self, tmp_path, monkeypatch): + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", lambda n: None) + root = tmp_path / "efirmware" + (root / "_ebuild" / "tools").mkdir(parents=True) + (root / "CMakeLists.txt").touch() + tool = root / "_ebuild" / "tools" / "efwtool" + tool.touch() + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + assert find_efwtool(tmp_path) == tool + + +class TestMissingToolMessage: + def test_it_names_setup_when_the_checkout_is_absent(self, tmp_path): + """"efwtool not found" leaves the developer guessing at what fetches + it.""" + assert "ebuild setup" in missing_tool_message(tmp_path) + + def test_it_gives_the_build_command_when_the_checkout_exists(self, tmp_path): + (tmp_path / "efirmware").mkdir() + msg = missing_tool_message(tmp_path) + assert "cmake" in msg + assert "ebuild setup" not in msg + + +class TestPack: + def test_a_missing_artifact_is_refused_before_the_tool_runs(self, tmp_path): + tool = _fake_efwtool(tmp_path, "exit 0\n") + with pytest.raises(FirmwareImageError, match="no artifact"): + pack(tool, tmp_path / "nope", tmp_path / "out.efw") + + def test_a_tool_that_writes_nothing_is_caught(self, tmp_path): + """efwtool exiting 0 without producing a file would otherwise be + reported as a successful package.""" + tool = _fake_efwtool(tmp_path, "exit 0\n") + payload = tmp_path / "app" + payload.write_bytes(b"\x7fELF") + with pytest.raises(FirmwareImageError, match="wrote no image"): + pack(tool, payload, tmp_path / "out.efw") + + def test_a_failing_tool_surfaces_its_own_message(self, tmp_path): + tool = _fake_efwtool(tmp_path, 'echo "bad magic" >&2\nexit 3\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + with pytest.raises(FirmwareImageError, match="bad magic"): + pack(tool, payload, tmp_path / "out.efw") + + def test_version_and_addresses_reach_the_tool(self, tmp_path): + tool = _fake_efwtool(tmp_path, 'echo "$@" > "$(dirname "$0")/argv.txt"\ntouch "$3"\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + pack(tool, payload, tmp_path / "out.efw", version="2.1.0", + load_addr="0x08000000", entry_addr="0x08000100") + argv = (tmp_path / "argv.txt").read_text() + assert "--version 2.1.0" in argv + assert "--load 0x08000000" in argv + assert "--entry 0x08000100" in argv + + def test_addresses_are_omitted_when_not_given(self, tmp_path): + """A host build has no load address, and passing an empty one would + make efwtool reject the call.""" + tool = _fake_efwtool(tmp_path, 'echo "$@" > "$(dirname "$0")/argv.txt"\ntouch "$3"\n') + payload = tmp_path / "app" + payload.write_bytes(b"x") + pack(tool, payload, tmp_path / "out.efw") + argv = (tmp_path / "argv.txt").read_text() + assert "--load" not in argv + assert "--entry" not in argv + + +class TestCommand: + def test_package_is_registered(self): + assert "package" in cli.commands + + def _project(self, tmp_path, built=True): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "node", "version": "2.1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "node", "type": "executable", + "sources": ["src/main.c"]}], + })) + if built: + (tmp_path / "_build").mkdir() + (tmp_path / "_build" / "node").write_bytes(b"\x7fELF" + b"\x00" * 64) + return tmp_path + + def test_it_refuses_before_a_build(self, tmp_path, monkeypatch): + """Packaging a stale or absent artifact silently is worse than + saying which command to run.""" + monkeypatch.chdir(self._project(tmp_path, built=False)) + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "ebuild build" in result.output + + def test_it_refuses_without_an_executable_target(self, tmp_path, monkeypatch): + (tmp_path / "build.yaml").write_text(yaml.safe_dump({ + "project": {"name": "lib", "version": "1.0"}, + "workspace": {"backend": "ninja", "build_dir": "build"}, + "toolchain": {"target": "host"}, + "targets": [{"name": "lib", "type": "static_library", + "sources": ["a.c"]}], + })) + monkeypatch.chdir(tmp_path) + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "nothing to package" in result.output + + def test_it_says_how_to_get_efwtool(self, tmp_path, monkeypatch): + """Pointing the cache at an empty directory as well as clearing PATH: + this machine has a real efwtool cached, and without both the test + passes by finding it and asserts nothing.""" + monkeypatch.chdir(self._project(tmp_path)) + monkeypatch.setattr("ebuild.build.firmware_image.shutil.which", + lambda n: None) + monkeypatch.setattr("ebuild.deps.EBUILD_REPOS_DIR", tmp_path / "empty") + result = CliRunner().invoke(cli, ["package"]) + assert result.exit_code == 1 + assert "ebuild setup" in result.output diff --git a/tests/unit/test_shipped_recipes.py b/tests/unit/test_shipped_recipes.py new file mode 100644 index 0000000..368b9fb --- /dev/null +++ b/tests/unit/test_shipped_recipes.py @@ -0,0 +1,68 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Every recipe shipped in recipes/ must be a usable pin. + +Four of the five shipped recipes could not be fetched at all: + +- littlefs and lwip carried ``checksum: sha256:placeholder``. That parses, so + nothing rejected it, and then every fetch failed with a checksum mismatch. +- mbedtls carried ``...b1490fcd73`` where the published digest ends + ``...b1490fcd38`` — the last two hex characters transposed. +- freertos carried a digest matching none of the release's assets. + +None of that is visible by reading the file; it only shows up when someone +tries to build the package. These tests make the shape of a recipe checkable +without a network round trip, so a placeholder or a truncated digest cannot be +committed again. They deliberately do not assert the digest *values* — that +needs the network, and pinning a value here would just duplicate the recipe. +""" + +import re +from pathlib import Path + +import pytest +import yaml + +RECIPES_DIR = Path(__file__).resolve().parents[2] / "recipes" +SHA256_RE = re.compile(r"^(?:sha256:)?[0-9a-f]{64}$") + +RECIPE_FILES = sorted(RECIPES_DIR.glob("*.yaml")) + + +def test_there_are_recipes_to_check(): + assert RECIPE_FILES, f"no recipes found under {RECIPES_DIR}" + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_pins_a_real_sha256(recipe_path): + raw = yaml.safe_load(recipe_path.read_text(encoding="utf-8")) + checksum = raw.get("checksum", "") + + assert checksum, f"{recipe_path.name}: no checksum, so the download is unverified" + assert SHA256_RE.match(checksum), ( + f"{recipe_path.name}: checksum {checksum!r} is not a sha256 digest. " + f"Expected 64 lowercase hex characters, optionally prefixed 'sha256:'." + ) + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_url_is_https(recipe_path): + raw = yaml.safe_load(recipe_path.read_text(encoding="utf-8")) + url = raw.get("url", "") + + assert url, f"{recipe_path.name}: no url" + assert url.startswith("https://"), ( + f"{recipe_path.name}: {url!r} is not https. A pin is worth much less " + f"over a transport anyone on the path can rewrite." + ) + + +@pytest.mark.parametrize("recipe_path", RECIPE_FILES, ids=lambda p: p.stem) +def test_recipe_loads_through_the_real_loader(recipe_path): + """The loader validates too; make sure the shipped files pass it.""" + from ebuild.packages.recipe import load_recipe + + recipe = load_recipe(recipe_path) + assert recipe.name + assert recipe.version