From b0a6422d03e8ef8a93ffdbcad22815b973080a40 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Fri, 28 Aug 2026 19:18:14 +0530 Subject: [PATCH 1/8] fix: restore the package import and reconcile the merged backend behaviour `import ebuild.build.dispatch` raises SyntaxError on master, so the CLI and five test modules fail outright. As with the other breakage below, the cause is overlapping PRs squash-merged on stale bases with no re-verification. - dispatch.py: configure() ended with two consecutive `else:` blocks from an unresolved conflict. The two blocks also disagreed about the exception -- tests/ebuild expects ValueError("Unknown build backend ''") while tests/unit expects RuntimeError matching "ninja". Both are legitimate readings: an unrecognized name is a bad argument, and a "ninja" that reaches the dispatcher is a CLI routing failure. Introduced UnknownBackendError(ValueError, RuntimeError) with a message covering both, raised from configure(), build(), and clean(). "ninja" is no longer a silent no-op in configure(), which is what let `ebuild build` report "Build completed successfully" without running a compiler. - ninja_backend.py: NinjaBackend._object_path() was deleted by a cflags refactor while both of its callers survived, so generate() died with AttributeError -- the default build backend did not work at all. Restored with its target-namespaced object paths, which is what stops two targets sharing a source from claiming the same output. - ninja_backend.py: the emitted `link_shared` rule was dead -- nothing used it -- and it hardcoded `-shared`, which is wrong on macOS. Shared libraries already link through the generic `link` rule with the platform's flag (-dynamiclib/-shared) and the -L/-l wiring in ldflags. Dropped the rule and updated tests/ebuild/test_ninja_backend.py, which asserted the dead rule, to cover the surviving behaviour instead. Its removal also unblocks test_static_library_unaffected, which asserts no "-shared" anywhere in the generated file. Verified: pytest tests/ 202 passed (201 passed + 1 skipped without the ninja package installed); tests/performance 1 passed. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/build/dispatch.py | 56 +++++++++++++++++------------- ebuild/build/ninja_backend.py | 20 ++++++++--- tests/ebuild/test_ninja_backend.py | 22 +++++++++--- 3 files changed, 65 insertions(+), 33 deletions(-) diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 0d708dfa..9e5bdaa1 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/ninja_backend.py b/ebuild/build/ninja_backend.py index 5aa2f374..a16d902a 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -104,6 +104,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 +139,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", diff --git a/tests/ebuild/test_ninja_backend.py b/tests/ebuild/test_ninja_backend.py index cca58122..cdbe90f0 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): From e6cb4f9d75aa58ecf9cb7649dd6b3896ba9e2a95 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Fri, 28 Aug 2026 20:14:44 +0530 Subject: [PATCH 2/8] ci: make the Windows leg of the test matrix able to run at all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The matrix includes windows-2022, where the default shell is PowerShell. The "Run test suite" step uses backslash line continuations, which PowerShell does not accept: ParserError: Missing expression after unary operator '--'. 3 | --cov=ebuild --cov-report=xml --cov-report=term-missing \ So the Windows jobs failed before pytest started — on master and on every branch. Marked `shell: bash`, which GitHub provides on Windows runners via Git Bash, keeping the command readable and identical across the three platforms. Also: `mypy .` aborted immediately with tests\__init__.py: error: Duplicate module named "tests" (also at ".\layers\eosuite\tests\__init__.py") Found 1 error in 1 file (errors prevented further checking) layers/eosuite/ vendors its own tests/ package. Because the step is continue-on-error, this went unnoticed and the type check has been checking zero files. Excluding layers/ makes it check 81 source files; it stays continue-on-error, so the 11 pre-existing findings it now reports are visible without gating the build. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f6c6c262..06b27267 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,8 +42,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 +61,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 \ From 49cd368dea2c9386b56521e7956efa22e9be286b Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Fri, 28 Aug 2026 20:39:43 +0530 Subject: [PATCH 3/8] fix(ninja): escape paths in build statements so Windows builds are valid With the Windows jobs actually running pytest, they surface a backend that has never worked on Windows: ninja: error: build.ninja:20: expected build command name build C:\...\main.o: cc main.c ^ near here Ninja splits build statements on unescaped spaces and colons, so a Windows absolute path puts a drive-letter colon where Ninja expects the separator between outputs and the rule name. Every generated build.ninja was rejected before a single command ran. A POSIX path containing a space fails the same way. _ninja_path() escapes `$`, `:` and ` ` and is applied to the paths in build statements only. Variable values (cflags, ldflags) are read to end of line and are deliberately left alone -- escaping them would hand the compiler mangled flags. Also skipped tests/ebuild/test_integration_initramfs_security.py on hosts without find(1)/cpio(1). _create_initramfs() drives both directly, so on a Windows runner all three died with WinError 2 before reaching the injection behaviour they exist to check. Building a Linux initramfs is not a Windows operation; the skip matches how test_ninja_backend.py already skips when no host C compiler is present. Adds four regression tests for the escaping, including one asserting that each build statement contains exactly one unescaped colon. Verified: pytest tests/ 206 passed. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/build/ninja_backend.py | 34 ++++++++++++-- .../test_integration_initramfs_security.py | 16 +++++++ tests/unit/test_ninja_backend.py | 47 ++++++++++++++++++- 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index a16d902a..d416c15e 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. @@ -155,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)}") @@ -182,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)}") @@ -200,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, @@ -216,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/tests/ebuild/test_integration_initramfs_security.py b/tests/ebuild/test_integration_initramfs_security.py index 8f4107b7..b7caef4a 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/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e973ca06..51f73f2e 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() From 842ca8dbf7f834aaf847c96bde3f3b46537623e8 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Fri, 28 Aug 2026 21:12:42 +0530 Subject: [PATCH 4/8] ci: add a concurrency group so superseded runs stop holding runners ci.yml is the only workflow in this repo without a concurrency group, and it is the heaviest one -- a matrix spanning ubuntu, macos and windows. Every push to a pull request therefore left the previous run queued, and all of them competed for the same scarce windows/macos runners. On this branch three superseded runs sat ahead of the current one for over an hour, testing commits that were no longer HEAD. Uses the same group expression the sibling workflows already use, with cancel-in-progress: true, because a superseded commit's result is not wanted. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 06b27267..b58d1acf 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: From 14928ab12178d9a9311caf68f4a91cd47f3f524b Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Fri, 28 Aug 2026 21:30:01 +0530 Subject: [PATCH 5/8] ci: move the test matrix off the retired macos-13 runner image The macos-13 image is retired, so jobs requesting it are never assigned a runner -- they sit queued until they time out. Across four runs on this branch every ubuntu-22.04 and windows-2022 job started and finished within minutes while all three macos-13 jobs stayed queued for more than two hours, leaving the workflow permanently incomplete. Every other workflow in this repo already targets macos-latest; ci.yml was the one place still pinning macos-13. release.yml also lists macos-13, alongside macos-14, in its wheel-build matrix. Left alone: building wheels against an older macOS deployment target may be deliberate there, and it does not gate pull requests. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b58d1acf..c665a6c7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -26,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: From 1613b6e0c420e74a259ee7be79f763ca6b7d0d43 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 07:07:42 -0700 Subject: [PATCH 6/8] =?UTF-8?q?feat(cli):=20add=20ebuild=20test=20and=20eb?= =?UTF-8?q?uild=20monitor=20=E2=80=94=20the=20last=20two=20golden-path=20s?= =?UTF-8?q?teps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §7 names eBuild's MVP responsibility as "Setup, configure, build, test, flash, monitor", and §7.1 gives the sequence verbatim. Two of the eight commands did not exist: ebuild setup EXISTS ebuild build EXISTS ebuild new EXISTS ebuild test MISSING ebuild configure EXISTS ebuild flash EXISTS ebuild monitor MISSING Twenty-three commands were present, and neither of the two the MVP definition names. The golden path stopped at step 5 of 8. test is the step that makes a first firmware run verified rather than merely completed — the stated success criterion — and monitor is how a developer sees it run at all. ebuild qemu is adjacent but different: it starts an emulator and cannot observe the board step 7 just flashed. ebuild test dispatches through the existing backend detection and reports what the runner printed. Counts are parsed from the runner's own summary for ctest, meson and cargo, and withheld when nothing recognisable was printed — a number derived from a zero exit status is a guess presented as a measurement. make has no standard summary, so its counts stay unknown and the exit status carries the verdict. The case that drove the design: ctest exits 0 when it finds nothing to run. Trusting the exit status reports a green suite for a project with no tests at all, which is worse than having no test command, because it actively reassures. An empty run is therefore a failure: zero tests [error] The runner completed without executing a single test. exit=1 1 pass 1 fail [error] Tests failed (1 passed, 1 failed). exit=1 1 pass 0 fail [ok] Tests passed (1 passed, 0 failed). exit=0 ebuild monitor opens a serial console, reading the baud rate from board.console.baud or monitor.baud in build.yaml and falling back to 115200. It auto-selects a port only when exactly one is present; with several it lists them and asks, because picking one at random is how a session ends up silently attached to the wrong board. An explicit --port is always honoured, since enumeration misses some adapters. Ctrl-C ends the session and closes the port — leaving it held makes the next run fail with a busy device that looks like a hardware fault. Also fixes a .gitignore trap hit while adding these. The rule "build/" matched ebuild/build/, the backend package, not just build output: $ touch ebuild/build/probe.py $ git status --porcelain ebuild/build/probe.py (nothing) A new module added there is silently untracked — it works locally and is simply absent from the repo. Fixed with a negation rather than anchoring to /build/, so examples/*/build/ stays ignored. Both verified. ebuild/build/probe.py ?? (now visible) examples/*/build/out.o still ignored Tests: 99 -> 121 passed, 0 failed. Closes #83 Co-Authored-By: Claude Opus 5 (1M context) --- .gitignore | 4 + README.md | 44 ++++- ebuild/build/dispatch.py | 59 +++++++ ebuild/cli/commands.py | 148 +++++++++++++++++ ebuild/firmware/monitor.py | 156 ++++++++++++++++++ .../unit/test_golden_path_test_and_monitor.py | 154 +++++++++++++++++ 6 files changed, 563 insertions(+), 2 deletions(-) create mode 100644 ebuild/firmware/monitor.py create mode 100644 tests/unit/test_golden_path_test_and_monitor.py diff --git a/.gitignore b/.gitignore index 64115a9d..320d01e4 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,10 @@ __pycache__/ *.egg dist/ build/ +# ...but ebuild/build/ is the backend package, not build output. Without +# this a new module added there is silently untracked: git status shows +# nothing at all, so it works locally and is simply absent from the repo. +!ebuild/build/ .eggs/ *.whl diff --git a/README.md b/README.md index 1fc890d2..8ff3a6e3 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,11 @@ Observed in the source tree: NuttX. - **Package graph** — install and list project packages (`ebuild add`, `ebuild list-packages`). -- **Firmware & flashing** — build RTOS firmware and flash images to targets - (`ebuild firmware`, `ebuild flash`; `ebuild/firmware/`). +- **Firmware, flashing & console** — build RTOS firmware, flash images to + targets, and watch them run (`ebuild firmware`, `ebuild flash`, + `ebuild monitor`; `ebuild/firmware/`). +- **Testing** — run the project's tests through the detected backend and report + the runner's own counts (`ebuild test`). - **System images** — root filesystem, kernel, and disk-image assembly (`ebuild system`; `ebuild/system/`). - **Project scaffolding** — generate projects and boards from templates @@ -77,6 +80,7 @@ not enough, because ebuild invokes `python -m ninja`. ebuild info # show what ebuild parsed from build.yaml ebuild build # resolve and build (auto-detects the backend) ebuild build -j 4 # build independent packages concurrently +ebuild test # run the project's tests ebuild clean # remove build artifacts ebuild --version ``` @@ -89,6 +93,42 @@ that each package's own build may already run parallel compile jobs, so a large `-j` can oversubscribe the machine. See [docs/dependency-management.md](docs/dependency-management.md#parallel-package-builds). +### The golden path + +Eight commands from nothing to a running board: + +```bash +ebuild setup # fetch eos and eboot +ebuild new hello-eos +cd hello-eos +ebuild configure --board +ebuild build +ebuild test +ebuild flash +ebuild monitor # serial console; Ctrl-C to exit +``` + +`ebuild test` reports the counts the underlying runner printed, and reports +nothing when the runner printed no summary it recognises — a number inferred +from a zero exit status would be a guess presented as a measurement. + +It also treats a run that executed **no tests** as a failure. `ctest` exits `0` +when it finds nothing to run, so trusting the exit status alone would report a +green suite for a project containing no tests at all. + +`ebuild monitor` reads the console baud rate from `board.console.baud` or +`monitor.baud` in `build.yaml`, falling back to 115200. It auto-selects the +serial port only when exactly one is present; with several it lists them and +asks, rather than attaching to the wrong board. `ebuild monitor --list` shows +what the host can see. + +```yaml +# build.yaml +board: + console: + baud: 9600 +``` + Additional commands: `configure`, `install`, `add`, `list-packages`, `pipeline`, `system`, `firmware`, `flash`, `new`, `generate-project`, `generate-board`, `generate-boot`, `analyze`, `setup`, and the `repos` group diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index 9e5bdaa1..b6d0a44a 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -10,8 +10,10 @@ from __future__ import annotations import logging +import re import subprocess import sys +from dataclasses import dataclass from pathlib import Path from typing import Any, Dict, List, Optional @@ -247,3 +249,60 @@ def clean( ) else: raise _unknown_backend(backend, "cleaned") + + + def test( + self, + backend: str, + config: Optional[Dict[str, Any]] = None, + ) -> "TestOutcome": + """Run the backend's test step and report what its runner printed. + + Counts come from parsing the runner's own summary, never from the exit + status. A command that reported "N passed" because the process happened + to exit 0 would go green for a suite that never ran a single test, which + is the failure mode this whole command exists to prevent. When no + summary can be parsed the counts stay None and only ok/ran are + meaningful — reported honestly as unknown rather than filled in. + """ + config = config or {} + + if backend == "cmake": + cmd = ["ctest", "--test-dir", str(self.build_dir), "--output-on-failure"] + elif backend == "meson": + cmd = ["meson", "test", "-C", str(self.build_dir)] + elif backend == "cargo": + cmd = ["cargo", "test"] + elif backend in ("make", "kbuild"): + make_cmd = "nmake" if sys.platform == "win32" else "make" + cmd = [make_cmd, "-C", str(self.source_dir), "test"] + else: + return TestOutcome(ok=False, ran=False, output="", + reason="no test runner for the '%s' backend" % backend) + + cwd = str(self.source_dir) if backend == "cargo" else None + try: + proc = subprocess.run(cmd, capture_output=True, text=True, cwd=cwd) + except FileNotFoundError: + return TestOutcome( + ok=False, ran=False, output="", + reason="%s is not installed or not on PATH" % cmd[0]) + + output = (proc.stdout or "") + (proc.stderr or "") + passed, failed = _parse_test_counts(backend, output) + found_none = _found_no_tests(backend, output, passed, failed) + + # ctest exits 0 when it finds nothing to run, and prints "No tests were + # found!!!". Taken at face value that is a green test command for a + # project with no tests at all — the single most misleading result this + # command could produce, and the reason it treats an empty run as a + # failure rather than a pass. + return TestOutcome( + ok=proc.returncode == 0 and not found_none, + ran=True, + passed=passed, + failed=failed, + output=output, + returncode=proc.returncode, + found_none=found_none, + ) diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index b7b95228..24249eb2 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -1977,3 +1977,151 @@ def generate_board( except Exception as e: log.error(f"Board generation failed: {e}") raise SystemExit(1) + + +@cli.command() +@click.option("--config", "config_path", default="build.yaml", + help="Path to the project configuration.") +@click.option("--build-dir", default="build", help="Build output directory.") +@click.option("--backend", default=None, + help="Override the backend instead of using the configured one.") +@click.pass_obj +def test(log: Logger, config_path: str, build_dir: str, + backend: Optional[str]) -> None: + """Build and run the project's tests. + + Step 6 of the golden path, and the step that makes a first firmware run + verified rather than merely completed. + + Examples: + + ebuild test + + ebuild test --backend cmake --build-dir build + """ + log.header("ebuild — Test") + + try: + from ebuild.build.dispatch import BackendDispatcher, detect_backend + + log.step("Loading configuration...") + cfg = load_config(config_path) + log.info(f"Project: {cfg.name} v{cfg.version}") + + resolved_backend = backend or cfg.backend + if resolved_backend == "auto": + resolved_backend = detect_backend(cfg.source_dir) + log.info(f"Auto-detected backend: {resolved_backend}") + + build_path = Path(build_dir) + dispatcher = BackendDispatcher(cfg.source_dir, build_path) + + log.step(f"Running tests ({resolved_backend})...") + outcome = dispatcher.test( + backend=resolved_backend, + config=cfg.backend_config, + ) + + if outcome.output: + click.echo(outcome.output.rstrip()) + + if not outcome.ran: + log.error(outcome.reason or "tests did not run") + raise SystemExit(1) + + # The counts are reported exactly as the runner printed them, and are + # withheld when it printed none. Deriving "N passed" from a zero exit + # status would let a suite that never ran a single test report success, + # which is precisely the reassurance this command must not give. + if outcome.found_none: + log.error("The runner completed without executing a single test.") + log.info(" A green result here would mean nothing; treating it as a " + "failure.") + raise SystemExit(1) + + if outcome.counts_known: + log.info(f" {outcome.passed} passed, {outcome.failed} failed") + else: + log.warning(" the runner printed no summary this parser recognises; " + "reporting its exit status only") + + if not outcome.ok: + log.error(f"Tests failed ({outcome.summary()}).") + raise SystemExit(1) + + log.success(f"Tests passed ({outcome.summary()}).") + + except SystemExit: + raise + except FileNotFoundError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Test run failed: {e}") + raise SystemExit(1) + + +@cli.command() +@click.option("--port", default=None, + help="Serial port. Auto-selected when exactly one is present.") +@click.option("--baud", default=None, type=int, + help="Baud rate. Defaults to the project's board setting, else 115200.") +@click.option("--config", "config_path", default="build.yaml", + help="Project configuration to read the console baud rate from.") +@click.option("--list", "list_only", is_flag=True, default=False, + help="List the serial ports the host can see and exit.") +@click.pass_obj +def monitor(log: Logger, port: Optional[str], baud: Optional[int], + config_path: str, list_only: bool) -> None: + """Open a serial console on the target. + + Step 8 of the golden path. Ctrl-C ends the session. + + Examples: + + ebuild monitor + + ebuild monitor --port /dev/ttyUSB0 --baud 9600 + + ebuild monitor --list + """ + log.header("ebuild — Monitor") + + try: + from ebuild.firmware.monitor import ( + MonitorError, available_ports, baud_from_config, + monitor as run_monitor, resolve_port, + ) + + if list_only: + ports = available_ports() + if not ports: + log.warning("No serial ports found.") + return + log.step(f"{len(ports)} serial port(s):") + for p in ports: + log.info(f" {p}") + return + + device = resolve_port(port) + rate = baud if baud is not None else baud_from_config(config_path) + + log.step(f"Opening {device} at {rate} baud. Ctrl-C to exit.") + forwarded = run_monitor(port=device, baud=rate) + + # A session that saw nothing is worth saying out loud: it is the + # difference between "the board is quiet" and "the console is attached + # to the wrong port", and they look identical on screen. + if forwarded == 0: + log.warning(f"Session ended; no output was received from {device}.") + else: + log.success(f"Session ended; {forwarded} bytes received.") + + except SystemExit: + raise + except MonitorError as e: + log.error(str(e)) + raise SystemExit(1) + except Exception as e: + log.error(f"Monitor failed: {e}") + raise SystemExit(1) diff --git a/ebuild/firmware/monitor.py b/ebuild/firmware/monitor.py new file mode 100644 index 00000000..2bd0c9ea --- /dev/null +++ b/ebuild/firmware/monitor.py @@ -0,0 +1,156 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Serial console for the target — step 8 of the MVP golden path. + +`ebuild flash` puts an image on real hardware; this is how the developer sees +it run. That is why it talks to a serial port rather than wrapping `ebuild +qemu`, which starts an emulator and cannot observe the board that was just +flashed. +""" + +from __future__ import annotations + +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import List, Optional + +DEFAULT_BAUD = 115200 + + +class MonitorError(Exception): + """A monitor session could not be started or could not continue.""" + + +@dataclass +class PortInfo: + device: str + description: str = "" + + def __str__(self) -> str: + return self.device if not self.description else "%s (%s)" % ( + self.device, self.description) + + +def _require_pyserial(): + try: + import serial # noqa: F401 + from serial.tools import list_ports # noqa: F401 + except ImportError as exc: + raise MonitorError( + "pyserial is not installed. Install it with:\n" + " pip install pyserial" + ) from exc + import serial + from serial.tools import list_ports + return serial, list_ports + + +def available_ports() -> List[PortInfo]: + """Serial ports the host can currently see.""" + try: + _serial, list_ports = _require_pyserial() + except MonitorError: + return [] + return [PortInfo(p.device, p.description or "") for p in list_ports.comports()] + + +def resolve_port(port: Optional[str]) -> str: + """The port to open, or an error naming what the host can actually see. + + An explicit --port is honoured even when it is not in the enumerated list: + the enumeration misses some adapters, and refusing a port the developer + named because we failed to list it would be worse than letting open() say + so. Auto-selection only happens when exactly one port exists, because + picking one of several at random is how a monitor ends up silently + attached to the wrong board. + """ + if port: + return port + + ports = available_ports() + if not ports: + raise MonitorError( + "No serial ports found. Connect the board, or name the port " + "explicitly:\n" + " ebuild monitor --port /dev/ttyUSB0") + if len(ports) > 1: + listing = "\n".join(" %s" % p for p in ports) + raise MonitorError( + "Several serial ports are present; name the one to use with " + "--port:\n%s" % listing) + return ports[0].device + + +def baud_from_config(config_path: str = "build.yaml", + default: int = DEFAULT_BAUD) -> int: + """The board's console baud rate, or the default when nothing sets one. + + Looked for at board.console.baud and then monitor.baud, so a project can + state it once beside the rest of its board settings instead of passing + --baud on every invocation. + """ + path = Path(config_path) + 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 + if not isinstance(data, dict): + return default + + for section, key in (("board", "console"), ("monitor", None)): + node = data.get(section) + if not isinstance(node, dict): + continue + if key: + node = node.get(key) + if not isinstance(node, dict): + continue + baud = node.get("baud") + if isinstance(baud, int) and baud > 0: + return baud + return default + + +def monitor(port: Optional[str] = None, + baud: int = DEFAULT_BAUD, + timeout: float = 0.1, + stream=None) -> int: + """Stream the target's serial output until interrupted. + + Returns the number of bytes forwarded, so a caller can tell a session that + saw traffic from one that attached to a silent port. + """ + serial, _list_ports = _require_pyserial() + stream = stream if stream is not None else sys.stdout + device = resolve_port(port) + + try: + conn = serial.Serial(device, baud, timeout=timeout) + except Exception as exc: + raise MonitorError("Could not open %s at %d baud: %s" % ( + device, baud, exc)) from exc + + forwarded = 0 + try: + while True: + chunk = conn.read(4096) + if chunk: + forwarded += len(chunk) + stream.write(chunk.decode("utf-8", errors="replace")) + stream.flush() + except KeyboardInterrupt: + # Ctrl-C is how a monitor session ends. It is not an error, and it must + # still close the port — leaving it held makes the next run fail with a + # busy device that looks like a hardware fault. + pass + finally: + try: + conn.close() + except Exception: + pass + return forwarded diff --git a/tests/unit/test_golden_path_test_and_monitor.py b/tests/unit/test_golden_path_test_and_monitor.py new file mode 100644 index 00000000..9d11aa52 --- /dev/null +++ b/tests/unit/test_golden_path_test_and_monitor.py @@ -0,0 +1,154 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild test` and `ebuild monitor` — steps 6 and 8 of the golden path. + +§7 names eBuild's MVP responsibility as "Setup, configure, build, test, flash, +monitor" and §7.1 spells the sequence out. Neither of these two existed; the +path stopped at step 5 of 8. + +The case these tests exist for is the empty run. ctest exits 0 when it finds +nothing to execute, so the obvious implementation — trust the exit status — +reports a green test suite for a project containing no tests at all. That is a +worse outcome than having no test command, because it actively reassures. +""" + +import pytest + +from ebuild.build.dispatch import ( + TestOutcome, + _found_no_tests, + _parse_test_counts, +) + + +class TestCountParsing: + """Counts come from the runner's own summary, never from exit status.""" + + def test_ctest_summary(self): + out = "100% tests passed, 0 tests failed out of 17" + assert _parse_test_counts("cmake", out) == (17, 0) + + def test_ctest_summary_with_failures(self): + out = "50% tests passed, 3 tests failed out of 6" + assert _parse_test_counts("cmake", out) == (3, 3) + + def test_cargo_summary(self): + out = "test result: ok. 12 passed; 0 failed; 0 ignored" + assert _parse_test_counts("cargo", out) == (12, 0) + + def test_meson_summary(self): + out = "Ok: 12\nExpected Fail: 0\nFail: 2\n" + assert _parse_test_counts("meson", out) == (12, 2) + + def test_make_has_no_standard_summary(self): + # Rather than invent a format for make, the counts stay unknown and the + # exit status carries the verdict. Reporting a made-up number would be + # worse than reporting none. + assert _parse_test_counts("make", "anything at all") == (None, None) + + def test_unrecognised_output_yields_no_counts(self): + assert _parse_test_counts("cmake", "something else entirely") == (None, None) + + +class TestEmptyRunIsNotAPass: + """The trap: ctest exits 0 having run nothing.""" + + def test_ctest_no_tests_marker_is_detected(self): + assert _found_no_tests("cmake", "No tests were found!!!", None, None) is True + + def test_zero_totals_are_detected_even_without_a_marker(self): + # A runner that prints a well-formed summary adding up to nothing is + # the same situation reached by a different route. + assert _found_no_tests("cargo", "test result: ok.", 0, 0) is True + + def test_a_real_run_is_not_flagged(self): + assert _found_no_tests("cmake", "100% tests passed", 17, 0) is False + + def test_outcome_is_not_ok_when_nothing_ran(self): + o = TestOutcome(ok=False, ran=True, found_none=True, returncode=0) + assert o.ok is False + assert "found no tests" in o.summary() + + +class TestOutcomeReporting: + def test_counts_are_withheld_when_unparsed(self): + o = TestOutcome(ok=True, ran=True, returncode=0) + assert o.counts_known is False + assert "no summary" in o.summary() + + def test_counts_are_reported_when_parsed(self): + o = TestOutcome(ok=True, ran=True, passed=17, failed=0, returncode=0) + assert o.counts_known is True + assert o.summary() == "17 passed, 0 failed" + + def test_a_run_that_never_started_says_why(self): + o = TestOutcome(ok=False, ran=False, reason="ctest is not installed") + assert o.summary() == "ctest is not installed" + + +class TestMonitorPortResolution: + """monitor must fail fast and actionably with no hardware attached.""" + + def test_an_explicit_port_is_honoured(self): + # Enumeration misses some adapters. Refusing a port the developer named + # because we failed to list it would be worse than letting open() say so. + from ebuild.firmware.monitor import resolve_port + assert resolve_port("/dev/ttyUSB9") == "/dev/ttyUSB9" + + def test_no_ports_gives_an_actionable_error(self, monkeypatch): + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: []) + with pytest.raises(mon.MonitorError) as exc: + mon.resolve_port(None) + assert "--port" in str(exc.value) + + def test_several_ports_are_not_guessed_between(self, monkeypatch): + # Picking one of several at random is how a monitor session ends up + # silently attached to the wrong board. + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: [ + mon.PortInfo("/dev/ttyUSB0", "board A"), + mon.PortInfo("/dev/ttyUSB1", "board B"), + ]) + with pytest.raises(mon.MonitorError) as exc: + mon.resolve_port(None) + assert "/dev/ttyUSB0" in str(exc.value) + assert "/dev/ttyUSB1" in str(exc.value) + + def test_a_single_port_is_selected(self, monkeypatch): + from ebuild.firmware import monitor as mon + monkeypatch.setattr(mon, "available_ports", lambda: [ + mon.PortInfo("/dev/ttyACM0", "the only board"), + ]) + assert mon.resolve_port(None) == "/dev/ttyACM0" + + +class TestMonitorBaudRate: + def test_default_when_no_config_exists(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + assert baud_from_config(str(tmp_path / "absent.yaml")) == DEFAULT_BAUD + + def test_board_console_baud_is_read(self, tmp_path): + from ebuild.firmware.monitor import baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("board:\n console:\n baud: 9600\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == 9600 + + def test_monitor_section_is_read(self, tmp_path): + from ebuild.firmware.monitor import baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("monitor:\n baud: 57600\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == 57600 + + def test_a_malformed_config_falls_back_rather_than_raising(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("this: [is: not: valid", encoding="utf-8") + assert baud_from_config(str(cfg)) == DEFAULT_BAUD + + def test_a_nonsense_baud_is_ignored(self, tmp_path): + from ebuild.firmware.monitor import DEFAULT_BAUD, baud_from_config + cfg = tmp_path / "build.yaml" + cfg.write_text("monitor:\n baud: -1\n", encoding="utf-8") + assert baud_from_config(str(cfg)) == DEFAULT_BAUD From ead180692c0282bd67a959ffb504504feae25dd6 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 13:01:31 -0700 Subject: [PATCH 7/8] test: every ebuild command shown in the docs must exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit §8 lists "consistent documentation generated from tested examples" as an MLP requirement. Generating prose from examples is a large change; verifying that the examples already published actually run is the part that stops the docs lying, and it costs almost nothing. The failure this prevents has already happened in this organisation. EoStudio carried this, for the tool it is a front end for: f"ebuild --platform {platform} --config board.yaml {source_dir}" ebuild has no top-level --platform or --config. The string sat in a dict and was never executed, so nothing found out it could not work. A command published in a README decays the same way, except the person who discovers it is a developer following the quick start. Reads only fenced code blocks. Prose says things like "ebuild is a unified build system", and treating "is" as a subcommand would fail for no reason — a plain grep over the README yields "ebuild and", "ebuild can", "ebuild is" alongside the real ones. Three checks. Each documented invocation names a command the CLI registers; the eight §7.1 golden-path steps are asserted directly, so the path cannot be quietly broken by editing the README instead of the CLI; and a guard that the extraction found something at all, since a silently broken matcher would make every other assertion pass vacuously. Verified the check fails rather than merely existing: README: ebuild verify -> assert 'verify' in {'add', 'analyze', 'build', 'clean', ...} FAILED test_a_documented_command_exists[verify-README.md] restored -> 11 passed Tests: 121 -> 132 passed, 0 failed. Co-Authored-By: Claude Opus 5 (1M context) --- tests/unit/test_documented_commands_exist.py | 95 ++++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100644 tests/unit/test_documented_commands_exist.py diff --git a/tests/unit/test_documented_commands_exist.py b/tests/unit/test_documented_commands_exist.py new file mode 100644 index 00000000..375debe6 --- /dev/null +++ b/tests/unit/test_documented_commands_exist.py @@ -0,0 +1,95 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""Every ebuild command shown in the docs must exist. + +§8 lists "consistent documentation generated from tested examples" as an MLP +requirement. Generating prose from examples is a large change; verifying that +the examples we already publish actually run is the part that stops the docs +lying, and it is cheap. + +The failure this prevents is real and has bitten this organisation. EoStudio +carried this line for the tool it is a front end for: + + f"ebuild --platform {platform} --config board.yaml {source_dir}" + +ebuild has no top-level --platform or --config. The string was stored in a dict +and never executed, so nothing found out it could not work. A published command +nobody runs decays the same way, and a developer following the README is the one +who discovers it. + +Only fenced code blocks are read. Prose says things like "ebuild is a unified +build system", and treating "is" as a subcommand would make this fail for no +reason. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +DOCS = ["README.md", "demo.md"] + +#: A shell line invoking ebuild, e.g. "ebuild configure --board stm32f4" or +#: "$ ebuild build". Captures the subcommand only. +_INVOCATION = re.compile(r"^\s*(?:\$\s*)?ebuild\s+([a-z][a-z0-9-]*)", re.M) + +#: Long options are not subcommands. +_NOT_A_SUBCOMMAND = {"--help", "--version"} + + +def _code_blocks(text: str): + """The contents of every fenced code block.""" + return re.findall(r"```[a-zA-Z]*\n(.*?)```", text, re.S) + + +def _documented_commands(): + """(subcommand, source file) for every ebuild invocation in the docs.""" + found = [] + for name in DOCS: + path = REPO_ROOT / name + if not path.is_file(): + continue + text = path.read_text(encoding="utf-8", errors="replace") + for block in _code_blocks(text): + for sub in _INVOCATION.findall(block): + if sub not in _NOT_A_SUBCOMMAND: + found.append((sub, name)) + return found + + +def _real_commands(): + """Every subcommand the CLI actually registers.""" + from ebuild.cli.commands import cli + return set(cli.commands) + + +def test_the_docs_actually_show_commands(): + # A guard on the guard: if the extraction silently stopped matching, every + # assertion below would pass vacuously and the check would be worthless. + documented = _documented_commands() + assert len(documented) >= 5, ( + "expected several ebuild invocations in the docs, found %d — the " + "extraction is probably broken rather than the docs being empty" + % len(documented)) + + +@pytest.mark.parametrize("subcommand,source", sorted(set(_documented_commands()))) +def test_a_documented_command_exists(subcommand, source): + real = _real_commands() + assert subcommand in real, ( + "%s documents `ebuild %s`, which the CLI does not provide. " + "Available: %s" % (source, subcommand, ", ".join(sorted(real)))) + + +def test_every_golden_path_step_is_a_real_command(): + # §7.1 names these eight verbatim. They are asserted directly rather than + # via the docs so the golden path cannot be quietly broken by editing the + # README instead of the CLI. + real = _real_commands() + for step in ("setup", "new", "configure", "build", + "test", "flash", "monitor"): + assert step in real, "golden path step `ebuild %s` is missing" % step From 3d3374f5e7ad9163f637bdcea18698f8d6a1fd54 Mon Sep 17 00:00:00 2001 From: srpatcha Date: Sun, 30 Aug 2026 14:09:18 -0700 Subject: [PATCH 8/8] fix: restore TestOutcome lost while rebasing onto #66 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase resolution spliced test() into the middle of clean(), leaving clean()'s body orphaned under a second `def clean` and dropping the module-level TestOutcome dataclass with its two parser helpers entirely. The result imported far enough to collect and then failed: ImportError: cannot import name 'TestOutcome' from 'ebuild.build.dispatch' and before that, six of master's own clean() tests failed with NameError: name 'dry_run' is not defined because the shadowing duplicate did not take the keyword. This is the same defect this session has been repairing elsewhere — two versions of one function spliced by a merge that nothing recompiled — and it is worth naming rather than quietly fixing, because I introduced it by resolving a conflict with "keep both sides" without rebuilding afterwards. That is exactly how eos's sync.c ended up referencing fields no version of the file ever defined at once. clean() and test() are now separate again and the dataclass is back. Verified against the branch's base rather than a stale master: base (#66) 205 passed, 1 skipped this branch 240 passed, 1 skipped new failures none ebuild setup / new / configure / build / test / flash / monitor all present ebuild test on a real CMake project -> 1 passed, 0 failed exit 0 Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/build/dispatch.py | 98 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/ebuild/build/dispatch.py b/ebuild/build/dispatch.py index b6d0a44a..c23095ab 100644 --- a/ebuild/build/dispatch.py +++ b/ebuild/build/dispatch.py @@ -306,3 +306,101 @@ def test( returncode=proc.returncode, found_none=found_none, ) + + +@dataclass +class TestOutcome: + """What a backend's test run reported. + + ok is the runner's verdict. passed/failed are None when the runner printed + no summary this parser recognises; they are never guessed from ok, because + a green exit says nothing about how many tests ran. + """ + + # pytest collects any class named Test*; this is a result object, not a + # test case, and without the opt-out every run of a suite that imports it + # emits a PytestCollectionWarning. + __test__ = False + + ok: bool + ran: bool + passed: Optional[int] = None + failed: Optional[int] = None + output: str = "" + returncode: Optional[int] = None + reason: str = "" + found_none: bool = False + + @property + def counts_known(self) -> bool: + return self.passed is not None or self.failed is not None + + def summary(self) -> str: + if not self.ran: + return self.reason or "no tests were run" + if self.found_none: + return "the runner found no tests" + if not self.counts_known: + return "runner exited %s; it printed no summary this parser recognises" % ( + self.returncode,) + return "%d passed, %d failed" % (self.passed or 0, self.failed or 0) + + +# Each backend's own summary line. Anchored to the phrasing the tool prints so +# a format change shows up as unknown counts rather than as a wrong number. +_COUNT_PATTERNS = { + # ctest: "100% tests passed, 0 tests failed out of 17" + "cmake": re.compile( + r"tests passed,\s*(?P\d+)\s+tests? failed out of\s*(?P\d+)"), + # meson: "Ok: 12 Fail: 0" + "meson": re.compile( + r"^Ok:\s*(?P\d+).*?^Fail:\s*(?P\d+)", re.S | re.M), + # cargo: "test result: ok. 12 passed; 0 failed; 0 ignored" + "cargo": re.compile( + r"test result:.*?(?P\d+) passed;\s*(?P\d+) failed"), +} + + +def _parse_test_counts(backend: str, output: str): + """(passed, failed) from a runner's summary, or (None, None).""" + pattern = _COUNT_PATTERNS.get(backend) + if pattern is None: + # make has no standard summary format. Rather than invent one, report + # the counts as unknown and let the exit status carry the verdict. + return None, None + + match = pattern.search(output) + if not match: + return None, None + + groups = match.groupdict() + failed = int(groups["failed"]) + if "passed" in groups and groups["passed"] is not None: + return int(groups["passed"]), failed + # ctest reports failures out of a total; passed is the remainder. + return int(groups["total"]) - failed, failed + + +# Phrases each runner prints when it had nothing to run. ctest's is the one +# that matters: it pairs the message with a zero exit status. +_NO_TESTS_MARKERS = { + "cmake": ("No tests were found",), + "meson": ("No tests defined",), + "cargo": ("running 0 tests",), +} + + +def _found_no_tests(backend: str, output: str, + passed: Optional[int], failed: Optional[int]) -> bool: + """True when the runner completed having executed nothing. + + Checked two ways because neither alone is reliable: the marker phrase + catches ctest, which prints no summary at all in this case, and the counts + catch a runner that reports a well-formed summary totalling zero. + """ + for marker in _NO_TESTS_MARKERS.get(backend, ()): + if marker in output: + return True + if passed is not None and failed is not None: + return passed + failed == 0 + return False