Skip to content
Open
8 changes: 8 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# YAML is linted with yamllint's default new-lines rule (LF). The Windows
# runners check out with core.autocrlf=true, which turned every LF file into
# CRLF and failed the rule on line 1 of the first file -- except a file that
# already carried a stray CR, which autocrlf leaves alone. Pin the encoding
# instead of guessing at the checkout's, so the same bytes are linted on
# every OS.
*.yml text eol=lf
*.yaml text eol=lf
2 changes: 1 addition & 1 deletion .github/workflows/auto-assign.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,4 @@ jobs:
repo: context.repo.repo,
issue_number: context.payload.pull_request.number,
assignees: [context.payload.pull_request.user.login]
});
});
6 changes: 3 additions & 3 deletions .github/workflows/scorecard.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,14 @@ jobs:
security-events: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4
with:
persist-credentials: false
- uses: ossf/scorecard-action@v2.4.0
- uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3
with:
results_file: results.sarif
results_format: sarif
publish_results: true
- uses: github/codeql-action/upload-sarif@v3
- uses: github/codeql-action/upload-sarif@faaca9a8f6edddba5725ffe5adefdab6669a2eca # v3
with:
sarif_file: results.sarif
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,26 @@
`cjson` (v1.7.18), `nanopb` (v0.4.9.1), `lvgl` (v9.2.2), `tinyusb` (v0.18.0), and `unity` (v2.6.1).

### Fixed
- **CI on master runs to completion again.** ruff stopped the pipeline at its
first step on findings the merges had introduced (an F811 duplicate import,
W292, E402). `PackageRecipe.to_dict()` -- defined by #111, deleted by #112's
replay of the same file -- is restored; nine `test_index_sync` cases and mypy
had failed without it. It now emits `install_args`, which the original never
did, and hands back copies of its list fields rather than the live lists;
the index sync mapping carries `install_args` through to the cached recipe.
The vendored `core/eos/docs/three-way-alignment.md` is reverted to its pin
(the correction #109 made there is filed upstream as embeddedos-org/eos#149).
The OSSF Scorecard action moved to the ghcr.io-hosted release and is pinned
by commit. yamllint on the Windows legs: YAML is pinned to LF in
`.gitattributes`, so an existing Windows clone needs its files checked out
again once (`git rm --cached -r . && git reset --hard HEAD`, or a re-clone;
see CONTRIBUTING.md). On Python 3.10 and 3.11 `ebuild/plugins/__init__.py`
now type-checks: the `entry_points()` fallback is spelled out with a cast
instead of a `# type: ignore` naming the wrong error code.
(`ebuild/packages/recipe.py`, `ebuild/packages/index_sync.py`,
`ebuild/plugins/__init__.py`, `core/eos/docs/three-way-alignment.md`,
`.github/workflows/scorecard.yml`, `.gitattributes`, `.yamllint.yml`, and
the three lint-fixed test files.)
- **`ebuild test` now finds Windows test binaries.** The Ninja edge for a
native `type: test` target already carried the platform suffix
(`_exe_suffix()` names it `<name>.exe` on Windows), but `ebuild test`
Expand Down
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ This certifies you have the right to submit the code under the MIT license.
6. Commit with DCO sign-off: git commit -s
7. Push and create Pull Request

**Windows contributors:** `.gitattributes` pins `*.yml` and `*.yaml` to LF so
yamllint sees the same bytes on every platform. The attribute governs future
checkouts, not files already sitting in a working tree, and `git add
--renormalize .` rewrites only the index, never the files. After pulling that
change, from a clean tree run `git rm --cached -r . && git reset --hard HEAD`
once (or re-clone) so the YAML files are checked out again as LF; otherwise
yamllint will still see CRLF locally.

## Coding Standards

### C (ISO C11)
Expand Down
4 changes: 2 additions & 2 deletions core/eos/docs/three-way-alignment.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ This document tracks alignment between all three EoS components to ensure they r

| Dimension | eos | eboot | ebuild | Status |
|-----------|-----|-------|--------|--------|
| Board definitions | 84 board YAMLs in `eos/boards/` (upstream) | 83 board dirs / 138 `eboot_add_board()` calls in `eboot/boards/` (upstream, pinned rev) | `TARGET_ARCH` 14 + `MCU_TO_EBOOT_BOARD` 138 in `ebuild/sdk_generator.py` | ⚠️ Unverified: 84 vs 83 vs 14/138 — three inventories describe the same set and nothing cross-checks them; see the resolver drift note in PR #109 |
| Board definitions | 25 YAML files in `eos/boards/` | 25 board ports in `eboot/boards/` | `MCU_TO_EBOOT_BOARD` + `EOS_BOARD_MAP` in project generator | ✅ Aligned |
| Product profiles | 41 profiles in `eos/products/*.h` | — | `PRODUCT_MAP` (41 entries) in project generator | ✅ Aligned |
| Platform enum | — | 24 `eos_platform_t` entries in `eos_hal.h` | MCU_DATABASE (100+ MCUs) in hw analyzer | ✅ Aligned |
| Peripheral keywords | 33 HAL APIs in `hal.h` + `hal_extended.h` | — | `PERIPHERAL_KEYWORDS` (24 types) + `ComponentDB` (200+ parts) | ✅ Aligned |
Expand Down Expand Up @@ -118,7 +118,7 @@ Customer Input
│ LLMClient (optional) ──► deep analysis │
│ │
│ 2. EosProjectGenerator │
│ MCU_TO_EBOOT_BOARD (alias of ebuild/sdk_generator.py) ► eboot board dir │
│ MCU_TO_EBOOT_BOARD ──────────────────► eboot board dir │
│ EOS_BOARD_MAP ──────────────────────► eos board YAML │
│ PRODUCT_MAP (41 entries) ──────────► eos product .h │
│ MULTICORE_MCUS ─────────────────────► multicore config │
Expand Down
1 change: 1 addition & 0 deletions ebuild/packages/index_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,7 @@ def sync(
"dependencies": entry.get("dependencies", []),
"configure_args": entry.get("configure_args", []),
"build_args": entry.get("build_args", []),
"install_args": entry.get("install_args", []),
"patches": entry.get("patches", []),
}

Expand Down
31 changes: 31 additions & 0 deletions ebuild/packages/recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,37 @@ def validate(self) -> None:
f"Must be one of {self.VALID_BUILD_SYSTEMS}."
)

def to_dict(self) -> Dict[str, Any]:
"""Convert recipe to dictionary for YAML serialization."""
data: Dict[str, Any] = {
"package": self.name,
"version": self.version,
}
if self.description:
data["description"] = self.description
if self.license:
data["license"] = self.license
data["url"] = self.url
if self.checksum:
data["checksum"] = self.checksum
data["build"] = self.build_system
# Copies, not the live lists: a caller that appends to what it got
# back must not edit the recipe behind its back. The key order is the
# one index_sync's recipe_dict uses, with install_args after
# build_args; parse_recipe() reads every key by name, so a dump and a
# reload agree field for field regardless of order.
if self.dependencies:
data["dependencies"] = list(self.dependencies)
if self.configure_args:
data["configure_args"] = list(self.configure_args)
if self.build_args:
data["build_args"] = list(self.build_args)
if self.install_args:
data["install_args"] = list(self.install_args)
if self.patches:
data["patches"] = list(self.patches)
return data


def _parse_string_list(
raw: Dict[str, Any],
Expand Down
11 changes: 7 additions & 4 deletions ebuild/plugins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

import importlib.metadata
import logging
from typing import List
from typing import Any, List, Mapping, cast

from ebuild.plugins.base import PluginBase

Expand Down Expand Up @@ -41,9 +41,12 @@ def discover_plugins() -> List[PluginBase]:
if hasattr(entry_points, "select"):
eps = entry_points.select(group="ebuild.plugins")
else:
# Before 3.10 entry_points() returned a dict; the current stubs
# only model EntryPoints, which has no .get, hence the ignore.
eps = entry_points.get("ebuild.plugins", []) # type: ignore[attr-defined]
# Before 3.10, entry_points() returned a mapping of group name
# to entry points. The stubs only model the modern EntryPoints, so
# spell the old shape out rather than widen the ignore: the
# `# type: ignore[attr-defined]` that was here named the wrong
# error code, and mypy failed on the line anyway.
eps = cast(Mapping[str, Any], entry_points).get("ebuild.plugins", [])

for ep in eps:
try:
Expand Down
1 change: 0 additions & 1 deletion tests/ebuild/test_build_dir_resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,6 @@
import os
import shutil
import subprocess
import shutil
import textwrap
from pathlib import Path
from types import SimpleNamespace
Expand Down
67 changes: 66 additions & 1 deletion tests/ebuild/test_package_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
"""Tests for ebuild.packages.recipe."""

import pytest
import yaml

from ebuild.packages.recipe import (
PackageRecipe,
RecipeError,
_parse_recipe,
load_recipe_from_string,
Expand Down Expand Up @@ -114,4 +116,67 @@ def test_depends_alias_must_be_a_list():
"""

with pytest.raises(RecipeError, match="dependencies"):
load_recipe_from_string(content)
load_recipe_from_string(content)


def _fully_populated_recipe() -> PackageRecipe:
"""A recipe with every field set, so a round trip has to carry them all."""
return PackageRecipe(
name="demo",
version="1.2.3",
url="https://example.com/demo-1.2.3.tar.gz",
checksum="sha256:" + "ab" * 32,
build_system="autoconf",
dependencies=["zlib", "openssl"],
patches=["fix-build.patch"],
configure_args=["--enable-static"],
build_args=["VERBOSE=1"],
install_args=["DESTDIR=/tmp/stage"],
description="A demo package",
license="MIT",
)


def test_to_dict_round_trips_every_field():
"""Dumping to YAML and parsing it back must reproduce the recipe exactly.

to_dict() predates install_args and never emitted it, so a recipe cached
by index_sync came back with install_args == [] while every other field
survived. A field-for-field comparison catches the next one too.
"""
recipe = _fully_populated_recipe()

reloaded = parse_recipe(yaml.safe_load(yaml.safe_dump(recipe.to_dict())))

assert reloaded == recipe
assert reloaded.install_args == ["DESTDIR=/tmp/stage"]

# Key order is not a correctness property -- parse_recipe() reads every
# key by name, as recipe.py says -- but it is a stability property: the
# cached recipe YAML that index_sync writes is diffed by humans, and this
# keeps install_args next to build_args, where index_sync's recipe_dict
# puts it. If this fails after a deliberate reordering, update both
# emitters together and then this line; it is not a bug in to_dict().
keys = list(recipe.to_dict())
assert keys.index("install_args") == keys.index("build_args") + 1


def test_to_dict_returns_copies_not_live_lists():
"""Mutating a list from to_dict() must not reach into the recipe."""
recipe = _fully_populated_recipe()

data = recipe.to_dict()
for field_name in (
"dependencies",
"patches",
"configure_args",
"build_args",
"install_args",
):
data[field_name].append("injected")

assert recipe.dependencies == ["zlib", "openssl"]
assert recipe.patches == ["fix-build.patch"]
assert recipe.configure_args == ["--enable-static"]
assert recipe.build_args == ["VERBOSE=1"]
assert recipe.install_args == ["DESTDIR=/tmp/stage"]
8 changes: 4 additions & 4 deletions tests/unit/test_ci_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,12 @@
required check does not cover.
"""

import itertools
import re
from pathlib import Path

import yaml
import pytest
from pathlib import Path


WORKFLOWS_DIR = Path(__file__).resolve().parents[2] / ".github" / "workflows"
Expand Down Expand Up @@ -211,9 +214,6 @@ def test_gate_fails_on_any_non_success_result(jobs):
# check cannot say which of the three it means, and a Windows-only failure is
# indistinguishable from the other two legs without opening the run.

import itertools
import re

# `include` and `exclude` shape a matrix but are not dimensions of it, so they
# are not part of the cartesian product.
_NOT_A_DIMENSION = {"include", "exclude"}
Expand Down
37 changes: 37 additions & 0 deletions tests/unit/test_index_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
from unittest.mock import MagicMock, patch

import pytest
import yaml
from click.testing import CliRunner

from ebuild.cli.commands import cli
Expand Down Expand Up @@ -110,6 +111,42 @@ def test_index_sync_success(tmp_path):
assert "1.0.0" in content


def test_index_sync_caches_install_args(tmp_path):
"""An index entry's install_args must reach the cached recipe YAML.

The entry-to-recipe mapping listed every list field except this one, and
to_dict() never emitted it, so the cached copy of a package silently lost
the arguments its install step needs.
"""
mgr = IndexSyncManager(index_dir=tmp_path)

sample_index = [
{
"name": "staged-pkg",
"version": "2.0.0",
"url": "https://example.com/staged-pkg-2.0.0.tar.gz",
"checksum": "sha256:" + "ab" * 32,
"build_system": "make",
"install_args": ["DESTDIR=/tmp/stage", "PREFIX=/usr"],
}
]
raw_json = json.dumps(sample_index).encode("utf-8")

mock_resp = MagicMock()
mock_resp.read.return_value = raw_json
mock_resp.headers = {"Content-Length": str(len(raw_json))}
mock_resp.__enter__.return_value = mock_resp

with patch("urllib.request.urlopen", return_value=mock_resp):
res = mgr.sync(url="https://example.com/index.json", force=True)

assert res.package_count == 1
cached = yaml.safe_load(
(mgr.recipes_dir / "staged-pkg.yaml").read_text(encoding="utf-8")
)
assert cached["install_args"] == ["DESTDIR=/tmp/stage", "PREFIX=/usr"]


def test_index_sync_corrupted_json(tmp_path):
mgr = IndexSyncManager(index_dir=tmp_path)

Expand Down
Loading