From 662aecfbb3ec636bb236c5e049a34977c2701369 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Tue, 1 Sep 2026 16:20:15 +0530 Subject: [PATCH 1/2] fix(cli): repair a merge splice that left commands.py unparseable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit master does not parse. `ebuild` cannot start and pytest collects nothing: $ python -c "import ebuild.cli.commands" File "ebuild/cli/commands.py", line 855 """ebuild — A unified embedded OS build system.""" ^ SyntaxError: invalid character '—' (U+2014) Line 855 is a normal docstring. The real damage is 300 lines earlier: _board_config() lost its body and its closing quotes to a copy of _resolve_backend_request()'s last line, spliced in during a conflict resolution: def _board_config() -> Optional[Dict[str, Any]]: """The project's own board description, if it ships one. return resolved_backend, backend_config Its docstring never closes, so every subsequent triple-quote toggles the wrong way and the first em-dash the tokenizer reaches outside a string is reported instead -- at line 855, in a function that is fine. _resolve_backend_request itself still has its own return, so the line is a duplicate rather than a move. Restores the body from f5209ac, the last commit where this file parsed. The same resolution also dropped the whole of #89 -- _NO_TESTS_MARKERS, _TEST_COUNT_PATTERNS, _ran_no_tests, _parse_test_counts and both call sites -- leaving its tests behind and `test` back on: log.success("All tests passed.") reached unconditionally on a zero exit. ctest exits 0 when it finds nothing to run, so a project with enable_testing() and no add_test() reported a pass having executed nothing. Restored from the same commit. Collection goes from "cannot import" to 474 passing. The 51 that fail are pre-existing and were invisible while the module would not parse; they are not all one cause and are listed in the PR description. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/cli/commands.py | 95 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 92 insertions(+), 3 deletions(-) diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 6d7ecbea..237af8c5 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -521,7 +521,18 @@ def _report_footprint(cfg: "ProjectConfig", build_path: Path, log: Logger) -> No def _board_config() -> Optional[Dict[str, Any]]: """The project's own board description, if it ships one. - return resolved_backend, backend_config + 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( @@ -2411,7 +2422,10 @@ def test(log: Logger, config_path: str, build_dir: str, log.info(" ".join(argv)) try: - result = subprocess.run(argv, cwd=str(cwd)) + # Captured rather than inherited, because the exit status alone cannot + # distinguish "every test passed" from "there were no tests". The + # output is echoed below so the terminal reads as it did before. + result = subprocess.run(argv, cwd=str(cwd), capture_output=True, text=True) except FileNotFoundError: log.error( f"{name} is not installed or not on PATH, so the tests cannot be " @@ -2419,11 +2433,32 @@ def test(log: Logger, config_path: str, build_dir: str, ) raise SystemExit(1) + output = (result.stdout or "") + (result.stderr or "") + if output: + click.echo(output.rstrip()) + if result.returncode != 0: log.error(f"Tests failed ({name} exited {result.returncode}).") raise SystemExit(result.returncode) - log.success("All tests passed.") + # ctest exits 0 when it finds nothing to run. A CMakeLists with + # enable_testing() and no add_test() produces a CTestTestfile.cmake, so the + # runner is found, ctest prints "No tests were found!!!", exits 0, and the + # only honest reading of that is not "All tests passed". + if _ran_no_tests(name, output): + log.error(f"{name} completed without running a single test.") + log.info(" A pass here would mean nothing; treating it as a failure.") + raise SystemExit(1) + + counts = _parse_test_counts(name, output) + if counts is not None: + passed, failed = counts + log.success(f"All tests passed ({passed} passed, {failed} failed).") + else: + # No recognised summary. Report the verdict without inventing a number + # the runner did not print. + log.success("All tests passed.") + def _run_native_tests( @@ -2486,6 +2521,60 @@ def _run_native_tests( log.success(f"All {len(selected)} test targets passed.") +#: What each runner prints when it completed having executed nothing. ctest's is +#: the one that matters: it pairs the message with a zero exit status. +_NO_TESTS_MARKERS = { + "ctest": ("No tests were found",), + "meson test": ("No tests defined",), + "cargo test": ("running 0 tests",), +} + +#: Each runner's own summary line, anchored to the phrasing it prints so that a +#: format change shows up as "no counts" rather than as a wrong number. +_TEST_COUNT_PATTERNS = { + "ctest": re.compile( + r"tests passed,\s*(?P\d+)\s+tests? failed out of\s*(?P\d+)"), + "meson test": re.compile( + r"^Ok:\s*(?P\d+).*?^Fail:\s*(?P\d+)", re.S | re.M), + "cargo test": re.compile( + r"test result:.*?(?P\d+) passed;\s*(?P\d+) failed"), +} + + +def _ran_no_tests(name: str, output: str) -> bool: + """True when the runner finished having executed nothing. + + Checked two ways because neither is reliable alone: the marker phrase + catches ctest, which prints no summary at all in this case, and the counts + catch a runner that prints a well-formed summary totalling zero. + """ + for marker in _NO_TESTS_MARKERS.get(name, ()): + if marker in output: + return True + counts = _parse_test_counts(name, output) + return counts is not None and counts[0] + counts[1] == 0 + + +def _parse_test_counts(name: str, output: str): + """(passed, failed) from the runner's own summary, or None. + + `make test` has no standard summary format. Rather than invent one, its + counts stay unknown and the exit status carries the verdict. + """ + pattern = _TEST_COUNT_PATTERNS.get(name) + if pattern is None: + return None + match = pattern.search(output) + if not match: + return None + groups = match.groupdict() + failed = int(groups["failed"]) + if groups.get("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 + + def _resolve_test_runner( source_dir: Path, build_dir: Path, From fcc97e65b77d2c2873ee1bc3f6210441e9c82856 Mon Sep 17 00:00:00 2001 From: kartikey1306 Date: Tue, 1 Sep 2026 16:23:28 +0530 Subject: [PATCH 2/2] fix(ninja): the backend crashed on every target, and emitted an undefined rule Generating a build file fails outright on master: NameError: name '_ninja_path' is not defined _ninja_path was renamed escape_ninja_path, and three of its call sites were not updated -- the executable edge, the static_library edge and the shared_library edge. Between them that is every target type the backend emits, so `configure`/`build` on the ninja backend produce no build.ninja at all. Those three calls were also double-escaping: `out`, `obj_files` and `dep_archives` are each already escaped where they are built, so the wrapper is not just undefined but redundant. Dropped rather than renamed. With that fixed the generated file is still not usable for a shared_library, because the edge names a rule the file never defines: build build/libmylib.dylib: link_shared build/obj/mylib/lib.o $ ninja ninja: error: unknown rule 'link_shared' Adds the rule, carrying the shared-object flag so no edge has to repeat it, and _shared_flag() to pick it: -dynamiclib on macOS, -shared on ELF. Emitting the ELF spelling on macOS is what the two tests in this area were failing on. End to end, a shared_library project now builds: [1/2] CC build/obj/mylib/lib.o [2/2] LINK_SHARED build/libmylib.dylib $ file build/libmylib.dylib Mach-O 64-bit dynamically linked shared library arm64 tests/unit/test_ninja_backend.py referenced the pre-rename name in five assertions; updated to the exported one. 16/16 in the two ninja modules, and the suite goes from 474 to 498 passing. Co-Authored-By: Claude Opus 5 (1M context) --- ebuild/build/ninja_backend.py | 23 ++++++++++++++++++++--- tests/unit/test_ninja_backend.py | 10 +++++----- 2 files changed, 25 insertions(+), 8 deletions(-) diff --git a/ebuild/build/ninja_backend.py b/ebuild/build/ninja_backend.py index f3cfc76d..713ce5c3 100644 --- a/ebuild/build/ninja_backend.py +++ b/ebuild/build/ninja_backend.py @@ -31,6 +31,16 @@ class PackagePaths: "-fno-pie", "-fno-PIE"} +def _shared_flag() -> str: + """The flag that makes the compiler driver emit a shared object. + + macOS links dynamic libraries with -dynamiclib; ELF platforms use + -shared. It lives on the rule rather than on each edge so a + shared_library target cannot be emitted without it. + """ + return "-dynamiclib" if sys.platform == "darwin" else "-shared" + + def escape_ninja_path(path) -> str: """Escape a path for use inside a Ninja build statement. @@ -179,6 +189,13 @@ def _write_ninja(self) -> None: " command = $ar rcs $out $in", " description = AR $out", "", + # A shared_library edge names this rule. Without the rule the + # generated build.ninja referenced an undefined rule and ninja + # refused the whole file. + "rule link_shared", + f" command = $cc {_shared_flag()} $ldflags $in -o $out $libs", + " description = LINK_SHARED $out", + "", ] toolchain_ldflags = self._get_toolchain_ldflags() @@ -220,7 +237,7 @@ def _write_ninja(self) -> None: link_inputs = obj_files + dep_archives out = escape_ninja_path(self.build_dir / target.name) lines.append( - f"build {_ninja_path(out)}: link " f"{' '.join(_ninja_path(x) for x in link_inputs)}" + f"build {out}: link " f"{' '.join(link_inputs)}" ) if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") @@ -238,7 +255,7 @@ def _write_ninja(self) -> None: out = escape_ninja_path(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(x) for x in obj_files)}") + lines.append(f"build {out}: ar_rule " f"{' '.join(obj_files)}") else: # Shared libraries need the same -L/-l wiring executables # get, which the rule preamble alone does not supply. The @@ -254,7 +271,7 @@ def _write_ninja(self) -> None: for lib in pkg.libraries: libs.append(f"-l{lib}") - lines.append(f"build {_ninja_path(out)}: link_shared " f"{' '.join(_ninja_path(x) for x in obj_files)}") + lines.append(f"build {out}: link_shared " f"{' '.join(obj_files)}") if ldflags: lines.append(f" ldflags = {' '.join(ldflags)}") if libs: diff --git a/tests/unit/test_ninja_backend.py b/tests/unit/test_ninja_backend.py index e3c02b5d..b8d7fe41 100644 --- a/tests/unit/test_ninja_backend.py +++ b/tests/unit/test_ninja_backend.py @@ -92,15 +92,15 @@ class TestNinjaPathEscaping(unittest.TestCase): """ 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") + self.assertEqual(escape_ninja_path(r"C:\build\main.o"), r"C$:\build\main.o") + self.assertEqual(escape_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") + self.assertEqual(escape_ninja_path("a$b"), "a$$b") + self.assertEqual(escape_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"), + self.assertEqual(escape_ninja_path("/tmp/build/obj/app/src/main.o"), "/tmp/build/obj/app/src/main.o") def test_every_build_statement_has_one_unescaped_colon(self):