Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,7 @@ on:

# 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.
# queued, and both competed for the same scarce windows/macos runners.
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
Expand All @@ -27,10 +25,8 @@ jobs:
matrix:
python-version: ["3.10", "3.11", "3.12"]
# 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.
# requesting it are never assigned a runner and sit queued until they
# time out. Every other workflow in this repo already uses macos-latest.
os: [ubuntu-22.04, macos-latest, windows-2022]
fail-fast: false

Expand Down Expand Up @@ -61,7 +57,11 @@ jobs:
# 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 --exclude '^layers/'
# --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.
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/
Expand All @@ -75,8 +75,7 @@ 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: 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.
Expand Down
32 changes: 22 additions & 10 deletions ebuild/build/ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,25 @@ def _ninja_path(path) -> str:
return text.replace("$", "$$").replace(":", "$:").replace(" ", "$ ")


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.

Expand Down Expand Up @@ -202,8 +221,7 @@ def _write_ninja(self) -> None:
link_inputs = obj_files + dep_archives
out = str(self.build_dir / target.name)
lines.append(
f"build {_ninja_path(out)}: link "
f"{' '.join(_ninja_path(i) for i in link_inputs)}"
f"build {_ninja_path(out)}: link " f"{' '.join(_ninja_path(x) for x in link_inputs)}"
)
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
Expand All @@ -221,10 +239,7 @@ 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 {_ninja_path(out)}: ar_rule "
f"{' '.join(_ninja_path(o) for o in obj_files)}"
)
lines.append(f"build {_ninja_path(out)}: ar_rule " f"{' '.join(_ninja_path(x) for x in obj_files)}")
else:
# Shared libraries need the same -L/-l wiring executables
# get, which the rule preamble alone does not supply. The
Expand All @@ -240,10 +255,7 @@ def _write_ninja(self) -> None:
for lib in pkg.libraries:
libs.append(f"-l{lib}")

lines.append(
f"build {_ninja_path(out)}: link "
f"{' '.join(_ninja_path(o) for o in obj_files)}"
)
lines.append(f"build {_ninja_path(out)}: link_shared " f"{' '.join(_ninja_path(x) for x in obj_files)}")
if ldflags:
lines.append(f" ldflags = {' '.join(ldflags)}")
if libs:
Expand Down
41 changes: 41 additions & 0 deletions samples/eos_reference_board.kicad_pcb
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
(kicad_pcb (version 20221018) (generator eos_reference)

(title_block
(title "EoS Reference Board")
(rev "1.0")
(company "EmbeddedOS Foundation")
)

;; ── EoS memory map: (eos_mmap "NAME" "0xBASE" "0xSIZE" "FLAGS") ───────────
(eos_mmap "FLASH_NOR" "0x00000000" "0x04000000" "rx")
(eos_mmap "RAM_LPDDR4" "0x40000000" "0x20000000" "rwx")
(eos_mmap "HEAP_REGION" "0x60000000" "0x08000000" "rw")
(eos_mmap "OTA_SCRATCH" "0x68000000" "0x04000000" "rw")
(eos_mmap "UART0_PL011" "0x09000000" "0x00001000" "rw")

;; ── CPU ──────────────────────────────────────────────────────────────────
(eos_cpu
(arch "aarch64")
(core "cortex-a57")
(freq_mhz 1000)
(endian "little")
(fpu "neon-fp-armv8")
(stack_size "0x10000")
(entry_symbol "eos_kernel_entry")
(boot_symbol "_start")
)

;; ── Peripherals ──────────────────────────────────────────────────────────
(eos_peripheral
(name "uart0")
(compatible "arm,pl011")
(base "0x09000000")
(irq 33)
)
(eos_peripheral
(name "rtc0")
(compatible "arm,pl031")
(base "0x09010000")
(irq 34)
)
)
3 changes: 3 additions & 0 deletions tests/ebuild/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,9 @@ def test_shared_library_links_with_the_platform_shared_flag(tmp_path):
NinjaBackend(config, tmp_path / "build", toolchain).generate()

ninja_file = (tmp_path / "build" / "build.ninja").read_text(encoding="utf-8")
# macOS links dynamic libraries with -dynamiclib, ELF platforms with
# -shared. _shared_flag() picks the right one; asserting the literal
# "-shared" failed this test on every macOS runner.
shared_flag = "-dynamiclib" if sys.platform == "darwin" else "-shared"
assert f"rule link_shared\n command = $cc {shared_flag}" in ninja_file
assert "build " in ninja_file
Expand Down
150 changes: 150 additions & 0 deletions tests/unit/test_cad_pipeline.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 EoS Project

"""Tests for tools/cad_pipeline.py.

The pipeline had no test and no input in this repository -- the only board it
was ever pointed at lives in embeddedos-org/eFab, which does not exist, so it
had never been run. `samples/eos_reference_board.kicad_pcb` gives it one.

The bug these pin: `(eos_cpu(.*?))` and `(eos_peripheral(.*?))` are non-greedy,
so each stopped at the first `)` -- the one closing the block's *first*
attribute. Everything after it silently took a dataclass default. Counts stayed
correct, so the output looked right while describing different hardware.
"""

import importlib.util
import json
import subprocess
import sys
from pathlib import Path

import pytest

REPO = Path(__file__).resolve().parents[2]
TOOL = REPO / "tools" / "cad_pipeline.py"
SAMPLE = REPO / "samples" / "eos_reference_board.kicad_pcb"

ARTIFACTS = [
"eos_generated.ld",
"eos_generated.dts",
"eos_recipe.yaml",
"eos_toolchain.cmake",
"board_manifest.json",
]


def _load_tool():
spec = importlib.util.spec_from_file_location("cad_pipeline", TOOL)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod


def test_sample_board_exists():
"""The tool needs an input in this repo to be runnable at all."""
assert SAMPLE.is_file(), f"missing {SAMPLE}"


def test_pipeline_generates_every_artifact(tmp_path):
rc = subprocess.run(
[sys.executable, str(TOOL), str(SAMPLE), str(tmp_path)],
capture_output=True, text=True,
)
assert rc.returncode == 0, rc.stderr
for name in ARTIFACTS:
produced = tmp_path / name
assert produced.is_file(), f"{name} not generated"
assert produced.stat().st_size > 0, f"{name} is empty"
# The parser reads UTF-8; the writer must produce it. Without an
# explicit encoding, open() uses the platform default and these came
# out cp1252 on Windows.
produced.read_text(encoding="utf-8")


def test_every_cpu_attribute_is_parsed_not_defaulted():
"""A non-greedy block regex kept only the first attribute of (eos_cpu ...).

Every value below differs from CpuDef's default, so a parser that drops
them reports the defaults and fails here.
"""
mod = _load_tool()
board = mod.parse_kicad_pcb(str(SAMPLE))

assert board.cpu.arch == "aarch64"
assert board.cpu.core == "cortex-a57"
assert board.cpu.freq_mhz == 1000
assert board.cpu.endian == "little"
assert board.cpu.fpu == "neon-fp-armv8"
assert board.cpu.stack_size == 0x10000
assert board.cpu.entry_symbol == "eos_kernel_entry"
assert board.cpu.boot_symbol == "_start"


def test_cpu_attributes_track_the_file_not_the_defaults(tmp_path):
"""The decisive check: a board that is not the default must not read back
as the default. Against the unfixed parser this returns cortex-a57/1000."""
mod = _load_tool()
altered = (
SAMPLE.read_text(encoding="utf-8")
.replace('"cortex-a57"', '"cortex-a72"')
.replace("(freq_mhz 1000)", "(freq_mhz 1800)")
.replace('"eos_kernel_entry"', '"my_entry"')
)
board_file = tmp_path / "alt.kicad_pcb"
board_file.write_text(altered, encoding="utf-8")

board = mod.parse_kicad_pcb(str(board_file))
assert board.cpu.core == "cortex-a72"
assert board.cpu.freq_mhz == 1800
assert board.cpu.entry_symbol == "my_entry"


def test_peripheral_fields_are_parsed():
"""Every peripheral used to come back unknown/unknown/0x0/irq -1."""
mod = _load_tool()
board = mod.parse_kicad_pcb(str(SAMPLE))

assert len(board.peripherals) == 2
by_name = {p.name: p for p in board.peripherals}

assert set(by_name) == {"uart0", "rtc0"}
assert by_name["uart0"].compatible == "arm,pl011"
assert by_name["uart0"].base == 0x09000000
assert by_name["uart0"].irq == 33
assert by_name["rtc0"].compatible == "arm,pl031"
assert by_name["rtc0"].base == 0x09010000
assert by_name["rtc0"].irq == 34

for p in board.peripherals:
assert p.name != "unknown"
assert p.base != 0
assert p.irq >= 0


def test_memory_map_is_complete():
"""The regions the eos simulation workflow asserts on."""
mod = _load_tool()
board = mod.parse_kicad_pcb(str(SAMPLE))
names = {r.name for r in board.memory}
required = {"FLASH_NOR", "RAM_LPDDR4", "UART0_PL011", "HEAP_REGION", "OTA_SCRATCH"}
assert required <= names, f"missing {required - names}"
for r in board.memory:
assert r.size > 0
assert r.flags in ("rx", "rw", "rwx")


def test_generated_artifacts_carry_the_board_values(tmp_path):
"""The parsed values must reach the output, not just the manifest."""
subprocess.run(
[sys.executable, str(TOOL), str(SAMPLE), str(tmp_path)],
capture_output=True, text=True, check=True,
)
manifest = json.loads((tmp_path / "board_manifest.json").read_text())
assert manifest["cpu"]["core"] == "cortex-a57"

toolchain = (tmp_path / "eos_toolchain.cmake").read_text(encoding="utf-8")
assert "cortex-a57" in toolchain

dts = (tmp_path / "eos_generated.dts").read_text(encoding="utf-8")
assert "arm,pl011" in dts, "peripheral compatible string missing from the DTS"
11 changes: 10 additions & 1 deletion tests/unit/test_golden_path_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
regression guards for the steps that were missing or broken.
"""

import re
from pathlib import Path

import pytest
Expand Down Expand Up @@ -111,8 +112,16 @@ def test_test_target_links_like_an_executable(self, tmp_path):
SimpleNamespace(cc="cc", cxx="c++", ar="ar")).generate()
ninja = (tmp_path / "b" / "build.ninja").read_text(encoding="utf-8")

# Split on the first *unescaped* colon: that is the one separating
# outputs from the rule name. A plain l.split(":")[0] picks the drive
# letter apart from the rest on Windows, so the .o filter never matched
# and this selected the compile edge instead of the link edge.
def outputs(line):
return re.split(r"(?<!\$):", line, maxsplit=1)[0]

edge = next(l for l in ninja.splitlines()
if l.startswith("build ") and "t_smoke" in l and ".o" not in l.split(":")[0])
if l.startswith("build ") and "t_smoke" in l
and ".o" not in outputs(l))
assert ": link " in edge
assert ": ar_rule" not in edge

Expand Down
44 changes: 20 additions & 24 deletions tests/unit/test_ninja_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,9 +80,9 @@ 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;
Ninja read the statement as a rule separator and rejected every generated
file 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.
"""

Expand All @@ -98,27 +98,23 @@ 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)
def test_every_build_statement_has_one_unescaped_colon(self):
with tempfile.TemporaryDirectory() as tmp:
build_dir = Path(tmp) / "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
# The only unescaped colon is the one separating outputs from
# the rule name.
stripped = line.replace("$:", "").replace("$$", "")
self.assertEqual(stripped.count(":"), 1, line)


if __name__ == "__main__":
Expand Down
Loading
Loading