diff --git a/README.md b/README.md index 1fc890d2..421a3ae7 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,7 @@ not enough, because ebuild invokes `python -m ninja`. ```bash ebuild info # show what ebuild parsed from build.yaml ebuild build # resolve and build (auto-detects the backend) +ebuild test # run the project's tests ebuild build -j 4 # build independent packages concurrently ebuild clean # remove build artifacts ebuild --version @@ -95,6 +96,17 @@ Additional commands: `configure`, `install`, `add`, `list-packages`, (`status`, `update`, `set-url`, `set-branch`, `link`, `unlink`). Run `ebuild --help` for the full list. + +`ebuild test` reports the counts the underlying runner printed, and reports none +when it printed nothing recognisable — a number inferred from a zero exit status +is 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, and a `CMakeLists.txt` with `enable_testing()` and +no `add_test()` still produces a `CTestTestfile.cmake` — so the runner is found, +ctest prints `No tests were found!!!`, and trusting the exit status would report +a green suite for a project with no tests at all. + ## Test ```bash diff --git a/ebuild/cli/commands.py b/ebuild/cli/commands.py index 9a3848bb..442adc71 100644 --- a/ebuild/cli/commands.py +++ b/ebuild/cli/commands.py @@ -2155,71 +2155,10 @@ def generate_board( @cli.command() @click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def info(log: Logger, config_path: str) -> None: - """Show project info, targets, packages, and dependency graph.""" - log.header("ebuild — Project Info") - - try: - cfg = load_config(config_path) - - log.info(f"Project : {cfg.name}") - log.info(f"Version : {cfg.version}") - log.info(f"Source : {cfg.source_dir.resolve()}") - - if cfg.toolchain: - tc = cfg.toolchain - log.info(f"Compiler: {tc.compiler} (arch: {tc.arch})") - if tc.prefix: - log.info(f"Prefix : {tc.prefix}") - else: - log.info("Compiler: gcc (native)") - - if cfg.packages: - log.header("Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else "" - log.step(f"{p.name}{ver}") - - log.header("Targets") - for t in cfg.targets: - deps = f" depends=[{', '.join(t.depends)}]" if t.depends else "" - uses = f" uses=[{', '.join(t.uses)}]" if t.uses else "" - log.step(f"{t.name} ({t.target_type}){deps}{uses}") - if t.sources: - log.debug(f" sources: {t.sources}") - if t.cflags: - log.debug(f" cflags : {t.cflags}") - if t.ldflags: - log.debug(f" ldflags: {t.ldflags}") - - graph = build_dependency_graph(cfg.targets) - build_order = graph.topological_sort() - log.header("Build Order") - for i, name in enumerate(build_order, 1): - log.step(f"{i}. {name}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except ConfigError as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - except CycleError as e: - log.error(f"Dependency error: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option( - "--config", "config_path", + "--config", + "config_path", default="build.yaml", - type=click.Path(exists=False), + type=click.Path(), help="Path to the build configuration file.", ) @click.option( @@ -2228,1138 +2167,318 @@ def info(log: Logger, config_path: str) -> None: type=click.Path(), help="Build output directory.", ) +@click.option( + "--filter", + "name_filter", + default=None, + help="Only run tests whose name contains this substring.", +) @click.pass_obj -def install(log: Logger, config_path: str, build_dir: str) -> None: - """Resolve, fetch, and build all declared packages.""" - log.header("ebuild — Install Packages") +def test(log: Logger, config_path: str, build_dir: str, + name_filter: Optional[str]) -> None: + """Build and run the project's tests. + + Step six of the golden path. Delegates to whichever runner the project + already uses -- ctest for a CMake tree, `cargo test`, `meson test`, or + `make test` -- rather than imposing a test framework on the project. + """ + log.header("ebuild — Test") + + build_path = Path(build_dir) try: + log.step("Loading configuration...") cfg = load_config(config_path) log.info(f"Project: {cfg.name} v{cfg.version}") - - if not cfg.packages: - log.info("No packages declared in build.yaml.") - return - - build_path = Path(build_dir) - _install_packages(cfg, build_path, log, verbose=log.verbose) - log.success("All packages installed successfully.") - - except FileNotFoundError as e: - log.error(str(e)) + except FileNotFoundError: + log.error( + f"No {config_path} here. Run this from a project directory, or " + f"pass --config." + ) raise SystemExit(1) except (ConfigError, RecipeError) as e: log.error(f"Configuration error: {e}") raise SystemExit(1) - except (ResolveError, FetchError, BuildError) as e: - log.error(f"Package error: {e}") - raise SystemExit(1) + native = [t for t in cfg.targets if t.target_type == "test"] + if native: + _run_native_tests(cfg, native, build_path, log, name_filter) + return -def _no_recipe_message(name: str, registry) -> str: - """Say what is available, and what the developer probably meant. + runner = _resolve_test_runner(cfg.source_dir, build_path, name_filter) + if runner is None: + log.error( + "No test runner found for this project.\n" + " ebuild test drives the project's own runner. Add one of:\n" + " - CMake with enable_testing() + add_test() -> ctest\n" + " - a 'test' target in the Makefile -> make test\n" + " - Cargo.toml -> cargo test\n" + " - meson.build -> meson test" + ) + raise SystemExit(1) - "No recipe found" on its own leaves them guessing at the spelling, at - whether the package exists under another name, and at where recipes even - come from. - """ - import difflib + name, argv, cwd = runner + log.step(f"Running tests with {name}...") + log.info(" ".join(argv)) try: - available = sorted({r.name for r in registry.list_packages()}) - except Exception: - available = [] + # 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 " + f"run here." + ) + raise SystemExit(1) - lines = [f"No recipe for '{name}'."] - close = difflib.get_close_matches(name, available, n=3, cutoff=0.6) - if close: - lines.append(" Did you mean: " + ", ".join(close) + "?") - if available: - lines.append(" Available: " + ", ".join(available)) + 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) + + # 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: - lines.append(" No recipes are visible from here — is this a project " - "directory with a recipes/ folder?") - lines.append(f" To add it anyway: ebuild add {name} --force") - return "\n".join(lines) + # No recognised summary. Report the verdict without inventing a number + # the runner did not print. + log.success("All tests passed.") -@cli.command("add") -@click.argument("package_name") -@click.option("--version", "pkg_version", default=None, help="Package version to add.") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--force", is_flag=True, default=False, - help="Add a package with no recipe. It will not resolve until one exists.", -) -@click.pass_obj -def add_package(log: Logger, package_name: str, pkg_version: Optional[str], - config_path: str, force: bool) -> None: - """Add a package dependency to build.yaml.""" - log.header("ebuild — Add Package") +def _run_native_tests( + cfg: "ProjectConfig", + targets: List[Any], + build_path: Path, + log: Logger, + name_filter: Optional[str], +) -> None: + """Build and run the project's own ``test`` targets. - config_path_obj = Path(config_path) - if not config_path_obj.exists(): - log.error(f"Config file not found: {config_path}") + A scaffolded project has no CMake tree and no Makefile, so there is no + external runner to delegate to. The test binaries are ordinary ebuild + targets; build them the same way `ebuild build` does, then run each one + and treat a non-zero exit as a failure. + """ + selected = [t for t in targets if not name_filter or name_filter in t.name] + if not selected: + log.error(f"No test target matches --filter {name_filter!r}.") raise SystemExit(1) - # Verify the package exists in registry - recipe_dirs = _find_recipe_dirs(config_path_obj.parent) - if recipe_dirs: - registry = create_registry(*recipe_dirs) - recipe = registry.get(package_name, pkg_version) - if recipe: - log.info(f"Found recipe: {recipe.name} v{recipe.version}") - if pkg_version is None: - pkg_version = recipe.version - elif not force: - # Writing an entry that cannot resolve trades one clear error now - # for a confusing one at build time, in a file the developer has - # since committed. - log.error(_no_recipe_message(package_name, registry)) - raise SystemExit(1) + log.step("Building test targets...") + _configure_ninja_backend(cfg, build_path, log, suggest_build=False) + + from ebuild.build.dispatch import ninja_command + + # Ninja addresses targets by their output path, and `ebuild build` drives + # it with -f from the project root, so the same form is used here. + argv = ( + ninja_command() + + ["-f", str(build_path / "build.ninja")] + + [str(build_path / t.name) for t in selected] + ) + result = subprocess.run(argv) + if result.returncode != 0: + log.error("Test targets failed to build.") + raise SystemExit(result.returncode) + + failures: List[str] = [] + for target in selected: + binary = build_path / target.name + if not binary.is_file(): + log.error(f"{target.name}: built, but no binary at {binary}") + failures.append(target.name) + continue + + log.step(f"Running {target.name}...") + run = subprocess.run([str(binary)]) + if run.returncode == 0: + log.success(f" {target.name}: passed") else: - log.warning( - f"No recipe found for '{package_name}' — added because " - f"--force was given. It will not resolve until a recipe exists." - ) + log.error(f" {target.name}: exited {run.returncode}") + failures.append(target.name) - # Load and update config - with open(config_path_obj, "r", encoding="utf-8") as f: - raw = yaml.safe_load(f) + if failures: + log.error(f"{len(failures)} of {len(selected)} test targets failed: " + + ", ".join(failures)) + raise SystemExit(1) - if "packages" not in raw: - raw["packages"] = [] + log.success(f"All {len(selected)} test targets passed.") - # Check for duplicates - for p in raw["packages"]: - if isinstance(p, dict) and p.get("name") == package_name: - log.info(f"Package '{package_name}' already in build.yaml.") - return - entry: Dict[str, str] = {"name": package_name} - if pkg_version: - entry["version"] = pkg_version +#: 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",), +} - raw["packages"].append(entry) +#: 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"), +} - with open(config_path_obj, "w", encoding="utf-8") as f: - yaml.dump(raw, f, default_flow_style=False, sort_keys=False) - log.success(f"Added {package_name}" + (f" v{pkg_version}" if pkg_version else "") + f" to {config_path}") +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, + name_filter: Optional[str], +) -> Optional[Tuple[str, List[str], Path]]: + """Pick the test runner this project already uses. + + Returns ``(display_name, argv, cwd)``, or None when the project declares + no tests. Ordered so that an explicit CMake test registry wins over a + generic `make test` target in the same tree. + """ + if (build_dir / "CTestTestfile.cmake").is_file(): + argv = ["ctest", "--output-on-failure"] + if name_filter: + argv += ["-R", name_filter] + return "ctest", argv, build_dir + + if (source_dir / "Cargo.toml").is_file(): + argv = ["cargo", "test"] + if name_filter: + argv += [name_filter] + return "cargo test", argv, source_dir + + if (build_dir / "meson-info").is_dir(): + argv = ["meson", "test", "-C", str(build_dir)] + if name_filter: + argv += ["--suite", name_filter] + return "meson test", argv, source_dir + + makefile = next( + (source_dir / n for n in ("Makefile", "makefile", "GNUmakefile") + if (source_dir / n).is_file()), + None, + ) + if makefile is not None: + text = makefile.read_text(encoding="utf-8", errors="replace") + if re.search(r"^test\s*:", text, re.MULTILINE): + return "make test", ["make", "-C", str(source_dir), "test"], source_dir + + return None @cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--format", "img_format", - default="tar", - type=click.Choice(["raw", "qcow2", "tar", "ext4", "squashfs"]), - help="Output image format.", -) -@click.option( - "--size", "size_mb", - default=256, - type=int, - help="Image size in MB (for raw/ext4).", -) +@click.option("--port", default=None, + help="Serial device (e.g. /dev/ttyUSB0). Auto-detected if omitted.") +@click.option("--baud", default=115200, type=int, help="Baud rate.") @click.pass_obj -def system(log: Logger, config_path: str, build_dir: str, img_format: str, size_mb: int) -> None: - """Build a complete Linux system image (rootfs + kernel + image).""" - log.header("ebuild — System Image Build") - - try: - from ebuild.system.rootfs import RootfsBuilder - from ebuild.system.image import ImageBuilder +def monitor(log: Logger, port: Optional[str], baud: int) -> None: + """Attach a serial monitor to the target device. - build_path = Path(build_dir) + Step eight of the golden path -- the step that shows a developer their + first firmware run actually produced output. + """ + log.header("ebuild — Monitor") + log.info(f"Board: {_selected_board()}") + + if port is None: + candidates = _serial_ports() + if not candidates: + log.error( + "No serial device found.\n" + " Looked for /dev/ttyUSB*, /dev/ttyACM*, /dev/tty.usb*.\n" + " Connect the board, or name the device with --port." + ) + raise SystemExit(1) + if len(candidates) > 1: + log.error( + "More than one serial device is connected, so ebuild will not " + "guess which one is the board:\n" + + "\n".join(f" {c}" for c in candidates) + + "\n Choose one with --port." + ) + raise SystemExit(1) + port = candidates[0] + log.info(f"Auto-detected {port}") - log.step("Assembling root filesystem...") - rootfs = RootfsBuilder(build_path) - rootfs_dir = rootfs.assemble(init_system="busybox", hostname="eos") - log.success(f"Rootfs assembled: {rootfs_dir}") + log.step(f"Opening {port} at {baud} baud... (Ctrl-C to exit)") - log.step(f"Creating {img_format} image...") - imager = ImageBuilder(build_path, log=log) - image_path = imager.create( - rootfs_dir=rootfs_dir, - image_format=img_format, - image_size_mb=size_mb, + try: + import serial # type: ignore[import-untyped] + except ImportError: + log.error( + "pyserial is not installed, so the monitor cannot open the port.\n" + " Install it with: pip install pyserial" ) - log.success(f"Image created: {image_path}") + raise SystemExit(1) + try: + with serial.Serial(port, baud, timeout=0.2) as conn: + while True: + chunk = conn.read(4096) + if chunk: + sys.stdout.write(chunk.decode("utf-8", errors="replace")) + sys.stdout.flush() + except KeyboardInterrupt: + log.info("") + log.success("Monitor closed.") except Exception as e: - log.error(f"System build failed: {e}") + log.error(f"Serial error on {port}: {e}") raise SystemExit(1) -@cli.command() -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.option( - "--build-dir", - default="_build", - type=click.Path(), - help="Build output directory.", -) -@click.option( - "--rtos", - default="generic", - type=click.Choice(["zephyr", "freertos", "nuttx", "generic"]), - help="Target RTOS.", -) -@click.option( - "--board", - default="generic", - help="Target board name.", -) -@click.pass_obj -def firmware(log: Logger, config_path: str, build_dir: str, rtos: str, board: str) -> None: - """Build RTOS firmware for an embedded target.""" - log.header("ebuild — Firmware Build") - - try: - from ebuild.firmware.firmware import FirmwareBuilder - - cfg = load_config(config_path) - log.info(f"Project: {cfg.name} v{cfg.version}") - - build_path = Path(build_dir) - builder = FirmwareBuilder(build_path, log=log) - - log.step(f"Building {rtos} firmware for {board}...") - output = builder.build( - source_dir=cfg.source_dir, - rtos=rtos, - board=board, - ) - log.success(f"Firmware built: {output}") - - except FileNotFoundError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Firmware build failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.argument("image", type=click.Path(exists=True)) -@click.option("--tool", default="openocd", - type=click.Choice(["openocd", "pyocd", "nrfjprog", "esptool", "stflash"]), - help="Flash tool to use.") -@click.option("--target", default="stm32f4", help="Target MCU/board.") -@click.option("--address", default="0x08000000", help="Flash base address (hex).") -@click.option("--reset-after", is_flag=True, default=False, help="Reset target after flashing.") -@click.pass_obj -def flash(log: Logger, image: str, tool: str, target: str, address: str, - reset_after: bool) -> None: - """Flash a firmware image to the target device. - - Supports OpenOCD, pyOCD, nrfjprog, esptool, and st-flash. - - Examples: - - ebuild flash firmware.bin --tool openocd --target stm32f4 - - ebuild flash app.bin --tool nrfjprog - - ebuild flash firmware.bin --tool esptool --address 0x10000 - - ebuild flash firmware.bin --tool pyocd --target nrf52840 --reset-after - """ - log.header("ebuild — Flash") - - try: - from ebuild.firmware.flash import flash as do_flash, reset as do_reset, FlashError - - image_path = Path(image) - addr = int(address, 0) - - log.step(f"Flashing {image_path.name} to {target} via {tool}...") - log.info(f" Address: {hex(addr)}") - - do_flash(image_path, tool=tool, target=target, address=addr) - log.success(f"Flash complete: {image_path.name}") - - if reset_after: - log.step("Resetting target...") - do_reset(tool=tool, target=target) - log.success("Target reset.") - - except FlashError as e: - log.error(str(e)) - raise SystemExit(1) - except Exception as e: - log.error(f"Flash failed: {e}") - raise SystemExit(1) - - -@cli.command("list-packages") -@click.option( - "--config", "config_path", - default="build.yaml", - type=click.Path(exists=False), - help="Path to the build configuration file.", -) -@click.pass_obj -def list_packages(log: Logger, config_path: str) -> None: - """List available package recipes and project packages.""" - log.header("ebuild — Package Registry") - - config_path_obj = Path(config_path) - project_dir = config_path_obj.parent if config_path_obj.exists() else Path(".") - - recipe_dirs = _find_recipe_dirs(project_dir) - if not recipe_dirs: - log.warning("No recipe directories found.") - return - - registry = create_registry(*recipe_dirs) - packages = registry.list_packages() - - if not packages: - log.info("No recipes found.") - return - - log.info(f"Available recipes ({len(packages)}):") - for recipe in packages: - deps = f" (depends: {', '.join(recipe.dependencies)})" if recipe.dependencies else "" - desc = f" — {recipe.description}" if recipe.description else "" - log.step(f"{recipe.name} v{recipe.version} [{recipe.build_system}]{deps}{desc}") - - # Show project packages if config exists - if config_path_obj.exists(): - try: - cfg = load_config(config_path_obj) - if cfg.packages: - log.header("Project Packages") - for p in cfg.packages: - ver = f" v{p.version}" if p.version else " (latest)" - status = "✓ recipe found" if registry.has(p.name, p.version) else "✗ no recipe" - log.step(f"{p.name}{ver} — {status}") - except (ConfigError, FileNotFoundError): - pass - - -@cli.command() -@click.argument("input_text", required=False) -@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (KiCad .kicad_sch, Eagle .sch, BOM .csv, YAML, text).") -@click.option("--output-dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.option("--llm", "use_llm", is_flag=True, default=False, help="Enable LLM-enhanced analysis (Ollama local or OPENAI_API_KEY).") -@click.pass_obj -def analyze(log: Logger, input_text: Optional[str], input_file: Optional[str], - output_dir: str, eos_schemas: Optional[str], use_llm: bool) -> None: - """Analyze hardware design and generate eos + eboot + ebuild configs. - - Accepts text description, KiCad schematic (.kicad_sch), Eagle schematic (.sch), - BOM CSV (.csv), or any text/YAML file. Auto-detects format by file extension. - - Generates board.yaml, boot.yaml, build.yaml, and eos_product_config.h. - - Examples: - - ebuild analyze "nRF52840 BLE sensor with I2C and SPI flash" - - ebuild analyze --file design.kicad_sch - - ebuild analyze --file design.sch - - ebuild analyze --file bom.csv - - ebuild analyze "STM32H7 with CAN Ethernet" --llm - """ - log.header("ebuild — Hardware Analysis") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - interpreter = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - if input_file: - path = Path(input_file) - log.step(f"Reading hardware design: {path}") - profile = interpreter.interpret_file(str(path)) - elif input_text: - log.step("Analyzing text description...") - profile = interpreter.interpret_text(input_text) - else: - log.error("Provide hardware description text or --file ") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - extra = "" - if p.config.get("i2c_addr"): - extra = f" (I2C addr: {p.config['i2c_addr']})" - log.info(f" - {p.peripheral_type}: {p.name}{extra}") - log.info(f"Confidence: {profile.confidence:.0%}") - - # Optional LLM-enhanced analysis - if use_llm: - log.step("Running LLM-enhanced analysis...") - llm_info = interpreter.llm_client.get_provider_info() - log.info(f" Provider: {llm_info}") - if interpreter.llm_client.is_available(): - profile = interpreter.analyze_with_llm(profile) - log.success(" LLM analysis complete") - else: - log.warning(" No LLM available. Install Ollama or set OPENAI_API_KEY.") - - log.step("Generating configs...") - generator = EosConfigGenerator(output_dir) - outputs = generator.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.step("Validating generated configs...") - validator = EosConfigValidator() - result = validator.validate_all(output_dir) - log.info(result.summary()) - - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - prompt = interpreter.generate_prompt(profile) - prompt_path = Path(output_dir) / "llm_prompt.txt" - prompt_path.write_text(prompt) - log.info(f"LLM prompt saved: {prompt_path}") - - log.success("Analysis complete.") - - except Exception as e: - log.error(f"Analysis failed: {e}") - raise SystemExit(1) - - -@cli.command("generate-project") -@click.option("--text", "input_text", default=None, help="Hardware description text.") -@click.option("--file", "input_file", type=click.Path(exists=True), help="Hardware design file (YAML, KiCad, BOM).") -@click.option("--config", "config_yaml", type=click.Path(exists=True), help="Existing board.yaml from ebuild analyze.") -@click.option("--eos-repo", type=click.Path(exists=True), default=None, help="Path to local eos repo. Auto-clones from GitHub if omitted.") -@click.option("--eboot-repo", type=click.Path(exists=True), default=None, help="Path to local eboot repo. Auto-clones from GitHub if omitted.") -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default GitHub URL).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default GitHub URL).") -@click.option("--clone-dir", default=None, type=click.Path(), help="Directory to clone repos into. Uses temp dir if omitted.") -@click.option("--output", default="_project", help="Output directory (copy mode).") -@click.option("--mode", type=click.Choice(["copy", "branch"]), default="copy", help="Output mode.") -@click.option("--branch", default=None, help="Git branch name (branch mode only).") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_project( - log: Logger, - input_text: Optional[str], - input_file: Optional[str], - config_yaml: Optional[str], - eos_repo: Optional[str], - eboot_repo: Optional[str], - eos_url: Optional[str], - eboot_url: Optional[str], - clone_dir: Optional[str], - output: str, - mode: str, - branch: Optional[str], - eos_schemas: Optional[str], -) -> None: - """Generate a stripped-down eos/eboot project for specific hardware. - - Analyzes hardware requirements and prunes the full eos and eboot - repositories to only the modules needed for the target hardware. - Auto-clones eos and eboot from GitHub when local repo paths are not given. - - Examples: - - # Auto-clone from GitHub — no local repos needed: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --output customer-ble-sensor - - # With local repos: - ebuild generate-project --text "nRF52 BLE sensor with I2C and SPI" \\ - --eos-repo ../eos --eboot-repo ../eboot --output customer-ble-sensor - - # From existing hardware analysis: - ebuild generate-project --config _generated/board.yaml \\ - --output gateway-project - - # Custom GitHub fork: - ebuild generate-project --text "STM32H7 industrial controller" \\ - --eos-url https://github.com/myorg/eos.git \\ - --eboot-url https://github.com/myorg/eboot.git \\ - --output industrial-project - - # Branch mode on local repos: - ebuild generate-project --config _generated/board.yaml \\ - --eos-repo ../eos --eboot-repo ../eboot \\ - --mode branch --branch customer/ble-sensor - """ - log.header("ebuild — Project Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_project_generator import EosProjectGenerator - - # Step 1: Obtain a HardwareProfile - if config_yaml: - log.step(f"Loading hardware profile from {config_yaml}...") - profile = _load_profile_from_board_yaml(config_yaml) - elif input_file: - log.step(f"Analyzing hardware design: {input_file}...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - path = Path(input_file) - if path.suffix == ".kicad_sch": - profile = analyzer.interpret_kicad(str(path)) - else: - content = path.read_text(encoding="utf-8", errors="replace") - if "," in content and len(content.split("\n")) > 2: - profile = analyzer.interpret_bom(content) - else: - profile = analyzer.interpret_text(content) - elif input_text: - log.step("Analyzing text description...") - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - profile = analyzer.interpret_text(input_text) - else: - log.error("Provide hardware description via --text, --file, or --config.") - raise SystemExit(1) - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - - # Step 2: Create generator and auto-clone repos if needed - generator = EosProjectGenerator( - eos_repo=eos_repo, - eboot_repo=eboot_repo, - eos_url=eos_url, - eboot_url=eboot_url, - ) - - if not eos_repo or not eboot_repo: - log.step("Cloning repos from GitHub (repos not provided locally)...") - generator.ensure_repos( - need_eos=(eos_repo is None), - need_eboot=(eboot_repo is None), - clone_dir=clone_dir, - ) - if generator.eos_repo and not eos_repo: - log.info(f" eos cloned to: {generator.eos_repo}") - if generator.eboot_repo and not eboot_repo: - log.info(f" eboot cloned to: {generator.eboot_repo}") - - manifest = generator.resolve_manifest(profile) - log.info(f"eos modules: {len(manifest.eos_dirs)} dirs, product={manifest.eos_product}") - log.info(f"eboot modules: {len(manifest.eboot_files)} core files, board={manifest.eboot_board}") - if manifest.eos_toolchain: - log.info(f"eos toolchain: {manifest.eos_toolchain}") - if manifest.eos_examples: - log.info(f"eos examples: {', '.join(manifest.eos_examples)}") - log.info(f"eos extras: {', '.join(manifest.eos_extras)}") - log.info(f"eboot extras: {', '.join(manifest.eboot_extras)}") - - log.step(f"Generating project ({mode} mode)...") - outputs = generator.generate( - profile=profile, - output=output, - mode=mode, - branch=branch, - ) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("Project generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Project generation failed: {e}") - raise SystemExit(1) - - -def _load_profile_from_board_yaml(board_yaml_path: str) -> "HardwareProfile": - """Load a HardwareProfile from a board.yaml produced by ``ebuild analyze``.""" - from ebuild.eos_ai.eos_hw_analyzer import ( - HardwareProfile, - PeripheralInfo, - ) - - path = Path(board_yaml_path) - data = yaml.safe_load(path.read_text()) - board = data.get("board", data) - - profile = HardwareProfile( - mcu=board.get("mcu", ""), - mcu_family=board.get("family", ""), - arch=board.get("arch", ""), - core=board.get("core", ""), - vendor=board.get("vendor", ""), - clock_hz=board.get("clock_hz", 0), - flash_size=board.get("memory", {}).get("flash", 0), - ram_size=board.get("memory", {}).get("ram", 0), - features=board.get("features", []), - ) - - for p in board.get("peripherals", []): - profile.peripherals.append(PeripheralInfo( - name=p.get("name", ""), - peripheral_type=p.get("type", ""), - bus=p.get("bus", ""), - )) - - return profile - - -@cli.command("new") -@click.argument("project_name") -@click.option( - "--template", "template_name", - default="bare-metal", - type=click.Choice(["bare-metal", "ble-sensor", "rtos-app", "linux-app", "secure-boot", "safety-critical"]), - help="Project template to use.", -) -@click.option( - "--board", "board_name", - default="generic", - help="Target board name (e.g., nrf52, stm32h7, rpi4, generic).", -) -@click.option( - "--output-dir", - default=None, - type=click.Path(), - help="Parent directory for the new project. Defaults to current directory.", -) -@click.pass_obj -def new(log: Logger, project_name: str, template_name: str, board_name: str, - output_dir: Optional[str]) -> None: - """Scaffold a new EoS project from a template. - - Creates a ready-to-build project directory with src/main.c, build.yaml, - eos.yaml, and README.md pre-configured for the selected template and board. - - Examples: - - ebuild new my-sensor --template ble-sensor --board nrf52 - - ebuild new my-controller --template rtos-app --board stm32h7 - - ebuild new my-app --template bare-metal - - ebuild new my-gateway --template linux-app --board rpi4 - """ - log.header("ebuild — New Project") - - # Resolve template directory - templates_dir = Path(__file__).resolve().parent.parent.parent / "templates" - template_dir = templates_dir / template_name - - if not template_dir.is_dir(): - log.error(f"Template '{template_name}' not found at {templates_dir}") - log.info(f"Available templates: {', '.join(t.name for t in templates_dir.iterdir() if t.is_dir())}") - raise SystemExit(1) - - # Resolve output directory - parent = Path(output_dir) if output_dir else Path(".") - project_dir = parent / project_name - - if project_dir.exists(): - log.error(f"Directory already exists: {project_dir}") - raise SystemExit(1) - - # Board → arch/toolchain mapping - board_map = { - "nrf52": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "nrf52840": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "nordic"}, - "stm32h7": {"arch": "arm", "core": "cortex-m7", "toolchain": "arm-none-eabi", "vendor": "st"}, - "stm32f4": {"arch": "arm", "core": "cortex-m4f", "toolchain": "arm-none-eabi", "vendor": "st"}, - "rpi4": {"arch": "arm64", "core": "cortex-a72", "toolchain": "aarch64-linux-gnu", "vendor": "broadcom"}, - "esp32": {"arch": "xtensa", "core": "lx6", "toolchain": "xtensa-esp32-elf", "vendor": "espressif"}, - "rp2040": {"arch": "arm", "core": "cortex-m0+", "toolchain": "arm-none-eabi", "vendor": "raspberrypi"}, - "tms570": {"arch": "arm", "core": "cortex-r5f", "toolchain": "arm-none-eabi", "vendor": "ti"}, - "am64x": {"arch": "hybrid", "core": "cortex-a53+r5f", "toolchain": "aarch64-linux-gnu", "vendor": "ti"}, - "generic": {"arch": "host", "core": "host", "toolchain": "host", "vendor": "generic"}, - } - board_info = board_map.get(board_name, board_map["generic"]) - - log.step(f"Creating project '{project_name}' from '{template_name}' template...") - log.info(f"Board: {board_name} (arch={board_info['arch']}, core={board_info['core']})") - - # Create project directory structure - src_dir = project_dir / "src" - src_dir.mkdir(parents=True) - - # Template variable substitution - replacements = { - "{{PROJECT_NAME}}": project_name, - "{{BOARD_NAME}}": board_name, - "{{ARCH}}": board_info["arch"], - "{{CORE}}": board_info["core"], - "{{TOOLCHAIN}}": board_info["toolchain"], - "{{VENDOR}}": board_info["vendor"], - "{{TEMPLATE}}": template_name, - } - - # Copy and process template files - file_mapping = { - "main.c.template": src_dir / "main.c", - "build.yaml.template": project_dir / "build.yaml", - "eos.yaml.template": project_dir / "eos.yaml", - "README.md.template": project_dir / "README.md", - } - - for template_file, output_path in file_mapping.items(): - src_path = template_dir / template_file - if not src_path.exists(): - log.warning(f"Template file missing: {template_file}") - continue - - content = src_path.read_text(encoding="utf-8") - for key, val in replacements.items(): - content = content.replace(key, val) - - output_path.write_text(content, encoding="utf-8") - log.success(f" {output_path.relative_to(parent)}") - - log.success(f"\nProject created: {project_dir}") - log.info("\nNext steps:") - log.info(f" cd {project_name}") - log.info(" ebuild build") - - -@cli.command("generate-boot") -@click.argument("boot_yaml", type=click.Path(exists=True)) -@click.option("--output-dir", default="_generated", help="Output directory.") -@click.pass_obj -def generate_boot(log: Logger, boot_yaml: str, output_dir: str) -> None: - """Generate eboot C headers, linker scripts, and pack scripts from boot.yaml.""" - log.header("ebuild — eboot Config Generation") - - try: - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - from ebuild.eos_ai.eos_validator import EosConfigValidator - - log.step(f"Validating {boot_yaml}...") - validator = EosConfigValidator() - result = validator.validate_boot(boot_yaml) - log.info(result.summary()) - - if not result.valid: - log.error("Boot config validation failed. Fix errors before generating.") - raise SystemExit(1) - - log.step("Generating eboot build inputs...") - integrator = EosBootIntegrator(output_dir) - outputs = integrator.generate_from_boot_yaml(boot_yaml) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - log.success("eboot configs generated successfully.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Generation failed: {e}") - raise SystemExit(1) - - -# ═══════════════════════════════════════════════════════════════ -# Dependency management commands -# ═══════════════════════════════════════════════════════════════ - -@cli.command() -@click.option("--eos-url", default=None, help="Git URL for eos repo (overrides default).") -@click.option("--eboot-url", default=None, help="Git URL for eboot repo (overrides default).") -@click.option("--eos-branch", default=None, help="Branch/tag for eos repo.") -@click.option("--eboot-branch", default=None, help="Branch/tag for eboot repo.") -@click.option("--eos-path", default=None, type=click.Path(exists=True), help="Link to local eos repo (no clone).") -@click.option("--eboot-path", default=None, type=click.Path(exists=True), help="Link to local eboot repo (no clone).") -@click.pass_obj -def setup( - log: Logger, - eos_url: Optional[str], - eboot_url: Optional[str], - eos_branch: Optional[str], - eboot_branch: Optional[str], - eos_path: Optional[str], - eboot_path: Optional[str], -) -> None: - """Clone eos + eboot repos to the local cache (~/.ebuild/repos/). - - On first run this clones both repos with default settings. - Use flags to override URLs, branches, or link to local repos. - - Examples: - - ebuild setup - - ebuild setup --eos-url https://github.com/myfork/eos.git - - ebuild setup --eboot-branch v0.2.0 - - ebuild setup --eos-path /path/to/local/eos - """ - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Setup") - mgr = DepsManager() - - try: - log.step("Setting up eos...") - eos_dir = mgr.setup("eos", url=eos_url, branch=eos_branch, path=eos_path) - log.success(f" eos: {eos_dir}") - - log.step("Setting up eboot...") - eboot_dir = mgr.setup("eboot", url=eboot_url, branch=eboot_branch, path=eboot_path) - log.success(f" eboot: {eboot_dir}") - - log.success("Setup complete. Repos are ready.") - except Exception as e: - log.error(f"Setup failed: {e}") - raise SystemExit(1) - - -@cli.group() -@click.pass_context -def repos(ctx: click.Context) -> None: - """Manage cached eos/eboot repositories.""" - pass - - -@repos.command("status") -@click.pass_obj -def repos_status(log: Logger) -> None: - """Show all repos, URLs, branches, and paths.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Status") - mgr = DepsManager() - entries = mgr.status() - - for info in entries: - log.step(f"{info['name']}") - log.info(f" URL: {info['url']}") - log.info(f" Branch: {info['branch']}") - if info.get("config_path"): - log.info(f" Linked: {info['config_path']}") - if info.get("cached"): - log.info(f" Cached: {info['cache_location']}") - log.info(f" Git: {info.get('git_branch', '?')} @ {info.get('git_commit', '?')}") - else: - log.info(" Cached: no") - - -@repos.command("update") -@click.argument("repo_name", required=False, default=None) -@click.pass_obj -def repos_update(log: Logger, repo_name: Optional[str]) -> None: - """Git pull latest for one or all repos.""" - from ebuild.deps.manager import DepsManager - - log.header("ebuild — Repo Update") - mgr = DepsManager() - results = mgr.update(repo_name) - - for name, result in results.items(): - if "updated" in result: - log.success(f" {name}: {result}") - elif "failed" in result: - log.error(f" {name}: {result}") - else: - log.info(f" {name}: {result}") - - -@repos.command("set-url") -@click.argument("repo_name") -@click.argument("url") -@click.pass_obj -def repos_set_url(log: Logger, repo_name: str, url: str) -> None: - """Change the git URL for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_url(repo_name, url) - log.success(f"Set {repo_name} URL to {url}") - - -@repos.command("set-branch") -@click.argument("repo_name") -@click.argument("branch") -@click.pass_obj -def repos_set_branch(log: Logger, repo_name: str, branch: str) -> None: - """Change the branch/tag for a repo.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.set_branch(repo_name, branch) - log.success(f"Set {repo_name} branch to {branch}") - - -@repos.command("link") -@click.argument("repo_name") -@click.argument("local_path", type=click.Path(exists=True)) -@click.pass_obj -def repos_link(log: Logger, repo_name: str, local_path: str) -> None: - """Link a repo to a local directory (no clone).""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.link(repo_name, local_path) - log.success(f"Linked {repo_name} → {Path(local_path).resolve()}") - - -@repos.command("unlink") -@click.argument("repo_name") -@click.pass_obj -def repos_unlink(log: Logger, repo_name: str) -> None: - """Remove local path override, reverting to cache.""" - from ebuild.deps.manager import DepsManager - - mgr = DepsManager() - mgr.unlink(repo_name) - log.success(f"Unlinked {repo_name} — will use cached clone.") - - -# ═══════════════════════════════════════════════════════════════ -# Board generation command -# ═══════════════════════════════════════════════════════════════ - -@cli.command("generate-board") -@click.option("--mcu", default=None, help="MCU name (e.g., stm32f407, nrf52840).") -@click.option("--from-kicad", "kicad_file", default=None, type=click.Path(exists=True), help="KiCad schematic (.kicad_sch).") -@click.option("--from-eagle", "eagle_file", default=None, type=click.Path(exists=True), help="Eagle schematic (.sch).") -@click.option("--from-bom", "bom_file", default=None, type=click.Path(exists=True), help="BOM CSV file.") -@click.option("--describe", "description", default=None, help="Text description of hardware.") -@click.option("--product", default=None, help="Product profile for auto-config (e.g., ble-sensor, gateway).") -@click.option("--output", "output_dir", default="_generated", help="Output directory for generated configs.") -@click.option("--eos-schemas", default=None, help="Path to eos/schemas/ for hardware vocabulary.") -@click.pass_obj -def generate_board( - log: Logger, - mcu: Optional[str], - kicad_file: Optional[str], - eagle_file: Optional[str], - bom_file: Optional[str], - description: Optional[str], - product: Optional[str], - output_dir: str, - eos_schemas: Optional[str], -) -> None: - """Generate board/boot/build YAML configs from hardware inputs. - - Accepts an MCU name, KiCad schematic, Eagle schematic, BOM CSV, - or text description. Generates board.yaml, boot.yaml, build.yaml, - eos_product_config.h, and eboot integration files. - - Examples: - - ebuild generate-board --mcu stm32f407 --output ./config/ - - ebuild generate-board --from-kicad design.kicad_sch --output ./config/ - - ebuild generate-board --from-eagle design.sch --output ./config/ - - ebuild generate-board --from-bom parts.csv --output ./config/ - - ebuild generate-board --describe "STM32H743 with CAN, SPI flash" --output ./config/ - - ebuild generate-board --mcu nrf52840 --product ble-sensor --output ./config/ - """ - log.header("ebuild — Board Config Generator") - - try: - from ebuild.eos_ai.eos_hw_analyzer import EosHardwareAnalyzer - from ebuild.eos_ai.eos_config_generator import EosConfigGenerator - from ebuild.eos_ai.eos_validator import EosConfigValidator - from ebuild.eos_ai.eos_boot_integrator import EosBootIntegrator - - analyzer = EosHardwareAnalyzer(eos_schemas_path=eos_schemas) - - # Determine input source - if kicad_file: - log.step(f"Analyzing KiCad schematic: {kicad_file}") - profile = analyzer.interpret_kicad(kicad_file) - elif eagle_file: - log.step(f"Analyzing Eagle schematic: {eagle_file}") - profile = analyzer.interpret_file(eagle_file) - elif bom_file: - log.step(f"Analyzing BOM: {bom_file}") - content = Path(bom_file).read_text(encoding="utf-8", errors="replace") - profile = analyzer.interpret_bom(content) - elif description: - log.step("Analyzing text description...") - profile = analyzer.interpret_text(description) - elif mcu: - log.step(f"Generating config for MCU: {mcu}") - profile = analyzer.interpret_text(mcu) - else: - log.error("Provide --mcu, --from-kicad, --from-eagle, --from-bom, or --describe.") - raise SystemExit(1) - - # Override MCU if explicitly provided alongside another input - if mcu and profile.mcu != mcu: - profile.mcu = mcu - - log.info(f"MCU: {profile.mcu or '(unknown)'} ({profile.core})") - log.info(f"Arch: {profile.arch or '(unknown)'}") - log.info(f"Peripherals: {len(profile.peripherals)} detected") - for p in profile.peripherals: - log.info(f" - {p.peripheral_type}: {p.name}") - - # Generate configs - log.step("Generating board/boot/build configs...") - gen = EosConfigGenerator(output_dir) - outputs = gen.generate_all(profile) - - for name, path in outputs.items(): - log.success(f" {name}: {path}") - - # Validate - log.step("Validating generated configs...") - validator = EosConfigValidator() - val_result = validator.validate_all(output_dir) - log.info(val_result.summary()) - - # Generate eboot integration files - log.step("Generating eboot integration files...") - integrator = EosBootIntegrator(output_dir) - boot_outputs = integrator.generate_from_boot_yaml(str(outputs["boot"])) - for name, path in boot_outputs.items(): - log.success(f" {name}: {path}") - - log.success("Board config generation complete.") - - except SystemExit: - raise - except Exception as e: - log.error(f"Board generation failed: {e}") - raise SystemExit(1) - - -@cli.command() -@click.option("--json", "as_json", is_flag=True, - help="Emit the checks as JSON, for CI.") -@click.pass_obj -def doctor(log: Logger, as_json: bool) -> None: - """Diagnose the build environment in one command. - - Reports what is installed, what is missing, and what each missing piece - would cost. Read-only: it names the fix rather than applying it. - - Exits non-zero only for problems that actually stop a build, so a - host-only machine with no cross toolchain still passes. - """ - from ebuild.system.doctor import exit_code, format_report, run_all - - checks = run_all() - - if as_json: - import json as _json - click.echo(_json.dumps( - [{"name": c.name, "status": c.status, - "detail": c.detail, "fix": c.fix} for c in checks], - indent=2, - )) - raise SystemExit(exit_code(checks)) - - log.header("ebuild — Environment") - for line in format_report(checks).splitlines(): - click.echo(line) - raise SystemExit(exit_code(checks)) - - -@cli.command() -@click.option("--config", "config_path", default="build.yaml", - type=click.Path(), help="Path to the build configuration file.") -@click.option("--build-dir", default="_build", type=click.Path(), - help="Build output directory.") -@click.option("--output", "output_path", default=None, type=click.Path(), - help="Destination .efw path. Defaults to .efw.") -@click.option("--load", "load_addr", default=None, - help="Load address, e.g. 0x08000000.") -@click.option("--entry", "entry_addr", default=None, - help="Entry address, e.g. 0x08000100.") -@click.pass_obj -def package(log: Logger, config_path: str, build_dir: str, - output_path: Optional[str], load_addr: Optional[str], - entry_addr: Optional[str]) -> None: - """Assemble the built artifact into an eFirmware `.efw` image. - - The step §29's development-to-device flow puts between eBuild and the - device. eFirmware implements the format and ships `efwtool`; this drives - it, so a developer does not have to know the tool exists. - """ - from ebuild.build.firmware_image import ( - FirmwareImageError, find_efwtool, missing_tool_message, pack, verify, - ) - from ebuild.deps import EBUILD_REPOS_DIR - - log.header("ebuild — Package") - - try: - cfg = load_config(config_path) - except FileNotFoundError: - log.error(f"No {config_path} here. Run this from a project directory.") - raise SystemExit(1) - except (ConfigError, RecipeError) as e: - log.error(f"Configuration error: {e}") - raise SystemExit(1) - - binaries = [t for t in cfg.targets if t.target_type == "executable"] - if not binaries: - log.error("No executable target in build.yaml — nothing to package.") - raise SystemExit(1) - - artifact = Path(build_dir) / binaries[0].name - if not artifact.is_file(): - log.error(f"No built artifact at {artifact}. Run 'ebuild build' first.") - raise SystemExit(1) - - efwtool = find_efwtool(Path(EBUILD_REPOS_DIR)) - if efwtool is None: - log.error(missing_tool_message(Path(EBUILD_REPOS_DIR))) - raise SystemExit(1) - - output = Path(output_path or f"{cfg.name}.efw") - log.step(f"Packing {artifact.name} -> {output}") - try: - pack(efwtool, artifact, output, version=cfg.version or "0.0.0", - load_addr=load_addr, entry_addr=entry_addr) - verdict = verify(efwtool, output) - except FirmwareImageError as exc: - log.error(str(exc)) - raise SystemExit(1) - - log.success(f"{output} ({output.stat().st_size} bytes)") - for line in verdict.splitlines(): - log.info(f" {line}") - log.info("") - log.info(f"Inspect it with: {efwtool} inspect {output}") +def _serial_ports() -> List[str]: + """Serial devices that look like an attached development board.""" + found: List[str] = [] + for pattern in ("/dev/ttyUSB*", "/dev/ttyACM*", "/dev/tty.usb*"): + found.extend(sorted(glob.glob(pattern))) + return found 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 diff --git a/tests/unit/test_empty_test_run_is_not_a_pass.py b/tests/unit/test_empty_test_run_is_not_a_pass.py new file mode 100644 index 00000000..ca3037e0 --- /dev/null +++ b/tests/unit/test_empty_test_run_is_not_a_pass.py @@ -0,0 +1,87 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2026 EoS Project + +"""`ebuild test` must not report success for a project with no tests. + +ctest exits 0 when it finds nothing to run. A CMakeLists.txt with +`enable_testing()` and no `add_test()` still produces a CTestTestfile.cmake, so +`_resolve_test_runner()` finds ctest, ctest prints "No tests were found!!!", +exits 0, and trusting that exit status reports a green suite for a project +containing no tests at all. + +Measured on master before this change, against a project with a configured +build tree and zero registered tests: + + $ ctest --test-dir _build + No tests were found!!! + ctest exit=0 + + $ ebuild test + [ok] All tests passed. + exit=0 + +That is worse than having no test command, because it actively reassures. +""" + +import pytest + +from ebuild.cli.commands import _parse_test_counts, _ran_no_tests + + +class TestEmptyRunDetection: + def test_ctest_no_tests_marker(self): + assert _ran_no_tests("ctest", "No tests were found!!!") is True + + def test_meson_no_tests_marker(self): + assert _ran_no_tests("meson test", "No tests defined.") is True + + def test_cargo_zero_tests_marker(self): + assert _ran_no_tests("cargo test", "running 0 tests") is True + + def test_zero_totals_are_caught_without_a_marker(self): + # A runner that prints a well-formed summary adding up to nothing is + # the same situation by a different route. + assert _ran_no_tests("ctest", + "100% tests passed, 0 tests failed out of 0") is True + + def test_a_real_run_is_not_flagged(self): + assert _ran_no_tests("ctest", + "100% tests passed, 0 tests failed out of 17") is False + + def test_a_failing_run_is_not_flagged(self): + # Failures are already handled by the exit status; this must not + # reclassify them as "nothing ran". + assert _ran_no_tests("ctest", + "50% tests passed, 3 tests failed out of 6") is False + + def test_make_has_no_marker_and_is_not_flagged(self): + assert _ran_no_tests("make test", "anything at all") is False + + +class TestCountParsing: + """Counts come from the runner's own summary, never from the exit status.""" + + def test_ctest_summary(self): + assert _parse_test_counts( + "ctest", "100% tests passed, 0 tests failed out of 17") == (17, 0) + + def test_ctest_summary_with_failures(self): + assert _parse_test_counts( + "ctest", "50% tests passed, 3 tests failed out of 6") == (3, 3) + + def test_cargo_summary(self): + assert _parse_test_counts( + "cargo test", "test result: ok. 12 passed; 0 failed; 0 ignored") == (12, 0) + + def test_meson_summary(self): + out = "Ok: 12\nExpected Fail: 0\nFail: 2\n" + assert _parse_test_counts("meson test", 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. A made-up number would be worse than + # none. + assert _parse_test_counts("make test", "anything at all") is None + + def test_unrecognised_output_yields_no_counts(self): + assert _parse_test_counts("ctest", "something else entirely") is None