From c18d3a697ef46296cf91459a7403031bf21244f1 Mon Sep 17 00:00:00 2001 From: gaoguobin Date: Fri, 17 Apr 2026 14:54:09 +0800 Subject: [PATCH] refactor(skill): switch eide rebuild to direct unify_builder runtime --- .codex/INSTALL.md | 8 + .codex/UPDATE.md | 23 +- README.md | 2 +- README.zh-CN.md | 2 +- runtime/python/eide_rebuild.py | 509 +------ runtime/python/eide_rebuild/__init__.py | 170 +++ runtime/python/eide_rebuild/builder_params.py | 191 +++ runtime/python/eide_rebuild/eide_model.py | 21 + runtime/python/eide_rebuild/executor.py | 359 +++++ runtime/python/eide_rebuild/platform.py | 22 + runtime/python/eide_rebuild/project.py | 45 + runtime/python/eide_rebuild/result_model.py | 171 +++ runtime/python/eide_rebuild/tools.py | 309 ++++ runtime/tests/test_eide_rebuild.py | 1287 ++++++++++++++++- runtime/tests/test_skill_bundle_sync.py | 18 +- scripts/sync_skill_runtime.py | 31 +- skills/eide-rebuild/SKILL.md | 14 +- .../assets/eide-rebuild.cli-bridge-0.1.0.vsix | Bin 5793 -> 0 bytes skills/eide-rebuild/scripts/eide_rebuild.py | 509 +------ .../scripts/eide_rebuild/__init__.py | 170 +++ .../scripts/eide_rebuild/builder_params.py | 191 +++ .../scripts/eide_rebuild/eide_model.py | 21 + .../scripts/eide_rebuild/executor.py | 359 +++++ .../scripts/eide_rebuild/platform.py | 22 + .../scripts/eide_rebuild/project.py | 45 + .../scripts/eide_rebuild/result_model.py | 171 +++ .../scripts/eide_rebuild/tools.py | 309 ++++ 27 files changed, 3856 insertions(+), 1123 deletions(-) create mode 100644 runtime/python/eide_rebuild/__init__.py create mode 100644 runtime/python/eide_rebuild/builder_params.py create mode 100644 runtime/python/eide_rebuild/eide_model.py create mode 100644 runtime/python/eide_rebuild/executor.py create mode 100644 runtime/python/eide_rebuild/platform.py create mode 100644 runtime/python/eide_rebuild/project.py create mode 100644 runtime/python/eide_rebuild/result_model.py create mode 100644 runtime/python/eide_rebuild/tools.py delete mode 100644 skills/eide-rebuild/assets/eide-rebuild.cli-bridge-0.1.0.vsix create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/__init__.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/builder_params.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/eide_model.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/executor.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/platform.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/project.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/result_model.py create mode 100644 skills/eide-rebuild/scripts/eide_rebuild/tools.py diff --git a/.codex/INSTALL.md b/.codex/INSTALL.md index f1b11fa..6c2fc06 100644 --- a/.codex/INSTALL.md +++ b/.codex/INSTALL.md @@ -45,6 +45,14 @@ cmd /d /c "mklink /J `"$skillNamespace`" `"$repoRoot\skills`"" Restart Codex so it rescans `~/.agents/skills`. +Run this environment check once: + +```powershell +python "$repoRoot\skills\eide-rebuild\scripts\eide_rebuild.py" doctor +``` + +Expected: one JSON object with `"ok": true` + Then use natural language or an explicit path: - `你自己编译验证下对不对` diff --git a/.codex/UPDATE.md b/.codex/UPDATE.md index fad28ac..1c76b52 100644 --- a/.codex/UPDATE.md +++ b/.codex/UPDATE.md @@ -1,10 +1,6 @@ # codex-eide-rebuild update for Codex -Use these instructions on Windows to update an existing local install and immediately refresh the VS Code bridge extension. - -## Before updating - -Close VS Code windows that are actively using the EIDE rebuild bridge. The bridge VSIX reinstall is most reliable after those windows are closed. +Use these instructions on Windows to update an existing local install and refresh the direct-builder runtime. ## Update steps @@ -31,27 +27,12 @@ if (-not (Get-Command git -ErrorAction SilentlyContinue)) { throw 'git is required before updating codex-eide-rebuild.' } -if (-not (Get-Command code -ErrorAction SilentlyContinue)) { - throw 'VS Code CLI command `code` is required before updating codex-eide-rebuild.' -} - git -C $repoRoot fetch --tags origin git -C $repoRoot switch main git -C $repoRoot pull --ff-only - -$vsixPath = Get-ChildItem -LiteralPath (Join-Path $repoRoot 'runtime\bridge\dist') -Filter 'eide-rebuild.cli-bridge-*.vsix' | - Sort-Object Name -Descending | - Select-Object -First 1 -ExpandProperty FullName - -if (-not $vsixPath) { - throw 'Bundled bridge VSIX is missing after update.' -} - -code --install-extension $vsixPath --force +python "$repoRoot\skills\eide-rebuild\scripts\eide_rebuild.py" doctor ``` ## After update Restart Codex so it rescans the skill namespace and picks up the updated docs. - -The next `rebuild` run will use the refreshed bridge extension and the updated runner. diff --git a/README.md b/README.md index a227c6e..df489d9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # codex-eide-rebuild -`codex-eide-rebuild` provides a GitHub-installable Codex skill, a Windows Python runner, and a minimal VS Code bridge extension for rebuilding Embedded IDE for VS Code projects and returning the complete `compiler.log` as plain text. +`codex-eide-rebuild` provides a GitHub-installable Codex skill and a Windows Python runner for rebuilding Embedded IDE for VS Code projects and returning one complete JSON result. ## Install diff --git a/README.zh-CN.md b/README.zh-CN.md index b09e05c..ac13ae2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,6 +1,6 @@ # codex-eide-rebuild -`codex-eide-rebuild` 提供三部分能力:GitHub 可安装的 Codex skill、Windows Python runner、极小的 VS Code 桥接扩展。它会触发 EIDE 的 `rebuild`,并把完整 `compiler.log` 以纯文本协议返回给 Agent。 +`codex-eide-rebuild` 提供两部分能力:GitHub 可安装的 Codex skill、Windows Python runner。它会直接触发 EIDE 的构建链路,并把完整结果以单个 JSON 返回给 Agent。 ## 安装 diff --git a/runtime/python/eide_rebuild.py b/runtime/python/eide_rebuild.py index c2313af..bcc9e1c 100644 --- a/runtime/python/eide_rebuild.py +++ b/runtime/python/eide_rebuild.py @@ -1,514 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations -import ctypes -import hashlib -import json -import locale -import os -import shutil -import subprocess -import sys -import tempfile -import time -import zipfile -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Iterator - -if os.name == "nt": - import msvcrt - - -# --- Constants --- - -CLI_PREFIX = "[EIDE-CLI]" -BRIDGE_PUBLISHER = "eide-rebuild" -BRIDGE_NAME = "cli-bridge" -BRIDGE_ID = f"{BRIDGE_PUBLISHER}.{BRIDGE_NAME}" -BRIDGE_VERSION = "0.1.0" -BRIDGE_READY_TIMEOUT_MS = 30_000 -PIPE_CONNECT_TIMEOUT_MS = 5_000 - -if os.name == "nt": - KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True) - GENERIC_READ = 0x80000000 - GENERIC_WRITE = 0x40000000 - OPEN_EXISTING = 3 - ERROR_PIPE_BUSY = 231 - INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value - - -# --- Errors --- - -class ExitError(RuntimeError): - def __init__(self, exit_code: int, message: str) -> None: - super().__init__(message) - self.exit_code = exit_code - - -# --- Data --- - -@dataclass -class ProtocolState: - workspace: str = "" - target: str = "" - log_path: str = "" - result: str = "error" - duration_ms: str = "" - exit_code: int = 7 - compiler_log: str = "" - - -# --- Protocol --- - -def write_stderr_line(message: str) -> None: - sys.stderr.write(f"{message}\n") - - -def render_protocol(state: ProtocolState) -> str: - lines = [ - f"{CLI_PREFIX} begin workspace={state.workspace}", - f"{CLI_PREFIX} target={state.target}", - f"{CLI_PREFIX} logPath={state.log_path}", - f"{CLI_PREFIX} result={state.result}", - f"{CLI_PREFIX} durationMs={state.duration_ms}", - f"{CLI_PREFIX} compiler-log-begin", - ] - output = "\n".join(lines) + "\n" - if state.compiler_log: - output += state.compiler_log - if not state.compiler_log.endswith("\n") and not state.compiler_log.endswith("\r"): - output += "\n" - output += f"{CLI_PREFIX} compiler-log-end\n" - output += f"{CLI_PREFIX} end exitCode={state.exit_code}\n" - return output - - -def show_usage() -> None: - write_stderr_line("Usage: python eide_rebuild.py rebuild ") - - -# --- Paths --- - -def ensure_windows() -> None: - if os.name != "nt": - raise ExitError(7, "This runner supports Windows only.") - - -def get_local_appdata_root() -> Path: - local_appdata = os.environ.get("LOCALAPPDATA") - if local_appdata: - return Path(local_appdata) - return Path.home() / "AppData" / "Local" - - -def get_registration_root() -> Path: - override = os.environ.get("EIDE_REBUILD_REGISTRATION_ROOT") - if override: - return Path(override) - return Path.home() / ".vscode" / "eide-rebuild" / "registrations" - - -def get_registration_roots() -> list[Path]: - roots = [get_registration_root()] - legacy_root = get_local_appdata_root() / ("EIDE" + "_CLI") / "registrations" - if legacy_root not in roots: - roots.append(legacy_root) - return roots - - -def get_extensions_root() -> Path: - override = os.environ.get("EIDE_REBUILD_EXTENSIONS_ROOT") - if override: - return Path(override) - return Path.home() / ".vscode" / "extensions" - - -def resolve_full_path(path_value: str) -> Path: - path_obj = Path(path_value).expanduser() - if not path_obj.exists(): - raise ExitError(2, f"Path does not exist: {path_value}") - return path_obj.resolve() - - -def resolve_workspace_path(input_path: str) -> Path: - resolved_path = resolve_full_path(input_path) - if resolved_path.is_dir(): - workspace_files = sorted(resolved_path.glob("*.code-workspace")) - if len(workspace_files) == 1: - return workspace_files[0].resolve() - if not workspace_files: - raise ExitError(2, f"No .code-workspace file found in directory: {resolved_path}") - raise ExitError(2, f"Multiple .code-workspace files found in directory: {resolved_path}") - - if resolved_path.suffix.lower() == ".code-workspace": - return resolved_path - - raise ExitError(2, f"Expected a .code-workspace file or a project directory: {resolved_path}") - - -def normalize_workspace_path(workspace_path: Path | str) -> str: - return str(Path(workspace_path).resolve()).replace("/", "\\").lower() - - -def get_workspace_hash(workspace_path: Path | str) -> str: - normalized = normalize_workspace_path(workspace_path) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] - - -def get_registration_path(workspace_path: Path | str) -> Path: - return get_registration_root() / f"{get_workspace_hash(workspace_path)}.json" - - -def get_registration_paths(workspace_path: Path | str) -> list[Path]: - workspace_hash = get_workspace_hash(workspace_path) - return [root / f"{workspace_hash}.json" for root in get_registration_roots()] - - -def get_bridge_vsix_name() -> str: - return f"{BRIDGE_ID}-{BRIDGE_VERSION}.vsix" - - -def discover_vsix_path(script_dir: Path) -> Path: - override = os.environ.get("EIDE_REBUILD_BRIDGE_VSIX") - if override: - vsix_path = Path(override) - if vsix_path.exists(): - return vsix_path.resolve() - raise ExitError(7, f"Bundled bridge VSIX not found: {vsix_path}") - - candidates = [ - script_dir.parent / "assets" / get_bridge_vsix_name(), - script_dir.parent / "bridge" / "dist" / get_bridge_vsix_name(), - script_dir.parent.parent / "runtime" / "bridge" / "dist" / get_bridge_vsix_name(), - ] - - for candidate in candidates: - if candidate.exists(): - return candidate.resolve() - - raise ExitError(7, f"Bundled bridge VSIX not found: {candidates[-1]}") - - -# --- Commands --- - -def get_code_command_path() -> Path: - override = os.environ.get("EIDE_REBUILD_CODE_CMD") - if override: - code_path = Path(override) - if code_path.exists(): - return code_path.resolve() - raise ExitError(3, f"Configured VS Code CLI command does not exist: {code_path}") - - for command_name in ("code", "code.cmd"): - command_path = shutil.which(command_name) - if command_path: - return Path(command_path) - - fallback_paths = [ - get_local_appdata_root() / "Programs" / "Microsoft VS Code" / "bin" / "code.cmd", - Path(os.environ.get("ProgramFiles", "")) / "Microsoft VS Code" / "bin" / "code.cmd", - Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft VS Code" / "bin" / "code.cmd", - ] - - for candidate in fallback_paths: - if str(candidate) and candidate.exists(): - return candidate.resolve() - - raise ExitError(3, "Cannot find VS Code CLI command 'code'.") - - -def run_code_command(command_path: Path, arguments: list[str]) -> subprocess.CompletedProcess[str]: - command_text = str(command_path) - if command_path.suffix.lower() in {".cmd", ".bat"}: - command = ["cmd.exe", "/d", "/c", command_text, *arguments] - else: - command = [command_text, *arguments] - - return subprocess.run( - command, - check=False, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -# --- Bridge install --- - -def is_bridge_installed() -> bool: - extensions_root = get_extensions_root() - if not extensions_root.exists(): - return False - return any(extensions_root.glob(f"{BRIDGE_ID}-*")) - - -def install_bridge_from_vsix_fallback(vsix_path: Path) -> None: - extensions_root = get_extensions_root() - target_dir = extensions_root / f"{BRIDGE_ID}-{BRIDGE_VERSION}" - temp_root = Path(tempfile.mkdtemp(prefix="eide-rebuild-bridge-")) - archive_root = temp_root / "archive" - extension_root = archive_root / "extension" - - try: - archive_root.mkdir(parents=True, exist_ok=True) - extensions_root.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(vsix_path, "r") as archive: - archive.extractall(archive_root) - if not (extension_root / "package.json").exists(): - raise ExitError(7, f"Bundled bridge VSIX is missing extension payload: {vsix_path}") - if target_dir.exists(): - shutil.rmtree(target_dir) - shutil.copytree(extension_root, target_dir) - finally: - shutil.rmtree(temp_root, ignore_errors=True) - - -def ensure_bridge_installed(code_command: Path, vsix_path: Path) -> bool: - if is_bridge_installed(): - return False - - result = run_code_command(code_command, ["--install-extension", str(vsix_path), "--force"]) - if result.returncode == 0 and is_bridge_installed(): - return True - - install_bridge_from_vsix_fallback(vsix_path) - if not is_bridge_installed(): - raise ExitError(7, f"Failed to install bridge extension from: {vsix_path}") - return True - - -# --- Registration --- - -def read_registration(workspace_path: Path) -> dict[str, Any] | None: - for registration_path in get_registration_paths(workspace_path): - if not registration_path.exists(): - continue - - try: - return json.loads(registration_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - registration_path.unlink(missing_ok=True) - - return None - - -def remove_registration(workspace_path: Path) -> None: - for registration_path in get_registration_paths(workspace_path): - registration_path.unlink(missing_ok=True) - - -# --- Named pipe --- - -@contextmanager -def open_named_pipe(pipe_name: str, timeout_ms: int) -> Iterator[Any]: - if os.name != "nt": - raise OSError("Named pipes require Windows.") - if not pipe_name: - raise OSError("Pipe name is missing.") - - pipe_path = fr"\\.\pipe\{pipe_name}" - deadline = time.monotonic() + (timeout_ms / 1000.0) - - while True: - handle = KERNEL32.CreateFileW( - pipe_path, - GENERIC_READ | GENERIC_WRITE, - 0, - None, - OPEN_EXISTING, - 0, - None, - ) - if handle != INVALID_HANDLE_VALUE: - file_descriptor = msvcrt.open_osfhandle(handle, os.O_BINARY) - stream = os.fdopen(file_descriptor, "r+b", buffering=0) - try: - yield stream - finally: - stream.close() - return - - error_code = ctypes.get_last_error() - remaining_ms = int(max(0, (deadline - time.monotonic()) * 1000)) - if remaining_ms <= 0: - raise ctypes.WinError(error_code) - if error_code == ERROR_PIPE_BUSY and KERNEL32.WaitNamedPipeW(pipe_path, remaining_ms): - continue - raise ctypes.WinError(error_code) - - -def read_pipe_line(stream: Any) -> str: - buffer = bytearray() - while True: - chunk = stream.read(1) - if not chunk: - break - if chunk == b"\n": - break - buffer.extend(chunk) - return buffer.decode("utf-8") - - -def test_registration_alive(registration: dict[str, Any]) -> bool: - pipe_name = str(registration.get("pipeName", "")) - try: - with open_named_pipe(pipe_name, 400): - return True - except OSError: - return False - - -def wait_for_registration(workspace_path: Path, timeout_ms: int) -> dict[str, Any]: - deadline = time.monotonic() + (timeout_ms / 1000.0) - while time.monotonic() < deadline: - registration = read_registration(workspace_path) - if registration: - if test_registration_alive(registration): - return registration - remove_registration(workspace_path) - time.sleep(0.5) - raise ExitError(4, "Timed out waiting for VS Code bridge registration.") - - -def invoke_bridge_request(registration: dict[str, Any], workspace_path: Path) -> dict[str, Any]: - request = json.dumps( - { - "requestId": f"req-{int(time.time() * 1000)}", - "action": "rebuild", - "workspacePath": str(workspace_path), - }, - separators=(",", ":"), - ) - pipe_name = str(registration.get("pipeName", "")) - - try: - with open_named_pipe(pipe_name, PIPE_CONNECT_TIMEOUT_MS) as stream: - stream.write(request.encode("utf-8") + b"\n") - stream.flush() - response_line = read_pipe_line(stream) - except OSError as error: - raise ExitError(4, f"Failed to communicate with VS Code bridge: {error}") from error - - if not response_line: - raise ExitError(4, "Bridge returned an empty response.") - - try: - return json.loads(response_line) - except json.JSONDecodeError as error: - raise ExitError(4, f"Bridge returned invalid JSON: {error}") from error - - -# --- Build flow --- - -def start_workspace_window(code_command: Path, workspace_path: Path, new_window: bool) -> None: - open_flag = "-n" if new_window else "-r" - result = run_code_command(code_command, [open_flag, str(workspace_path)]) - if result.returncode != 0: - raise ExitError(3, f"Failed to open workspace in VS Code: {workspace_path}") - - -def read_compiler_log_text(log_path: str) -> str: - path_obj = Path(log_path) - if not path_obj.exists(): - raise ExitError(8, f"compiler.log not found: {path_obj}") - try: - payload = path_obj.read_bytes() - except OSError as error: - raise ExitError(8, f"Failed to read compiler.log: {path_obj} ({error})") from error - - try: - return payload.decode("utf-8", errors="strict") - except UnicodeDecodeError: - return payload.decode(locale.getpreferredencoding(False), errors="replace") - - -def resolve_exit_code(response: dict[str, Any]) -> int: - if response.get("ok"): - return 0 - - error_code = str(response.get("errorCode", "")) - mapping = { - "BUILD_FAILED": 6, - "BUILD_NOT_STARTED": 5, - "BUSY": 4, - "WRONG_WORKSPACE": 4, - "EIDE_MISSING": 7, - "LOG_MISSING": 8, - } - return mapping.get(error_code, 7) - - -# --- Entry point --- - -def main(argv: list[str] | None = None) -> int: - ensure_windows() - arguments = list(sys.argv[1:] if argv is None else argv) - state = ProtocolState() - started_at = time.perf_counter() - - try: - if len(arguments) < 2: - show_usage() - raise ExitError(2, "Missing command or path.") - - command_name = arguments[0] - if command_name != "rebuild": - show_usage() - raise ExitError(2, f"Unsupported command: {command_name}") - - workspace_path = resolve_workspace_path(arguments[1]) - state.workspace = str(workspace_path) - - code_command = get_code_command_path() - vsix_path = discover_vsix_path(Path(__file__).resolve().parent) - bridge_installed_now = ensure_bridge_installed(code_command, vsix_path) - - registration = read_registration(workspace_path) - if not registration or not test_registration_alive(registration): - remove_registration(workspace_path) - start_workspace_window(code_command, workspace_path, new_window=bridge_installed_now) - registration = wait_for_registration(workspace_path, BRIDGE_READY_TIMEOUT_MS) - - response = invoke_bridge_request(registration, workspace_path) - exit_code = resolve_exit_code(response) - - if response.get("workspacePath"): - state.workspace = str(response["workspacePath"]) - if response.get("target"): - state.target = str(response["target"]) - if response.get("logPath"): - state.log_path = str(response["logPath"]) - if response.get("durationMs") is not None: - state.duration_ms = str(response["durationMs"]) - - state.exit_code = exit_code - state.result = "success" if exit_code == 0 else "failure" if exit_code == 6 else "error" - - if state.log_path and exit_code in {0, 6}: - state.compiler_log = read_compiler_log_text(state.log_path) - - if exit_code == 8: - write_stderr_line(f"compiler.log missing or unreadable: {state.log_path}") - elif not response.get("ok"): - write_stderr_line(str(response.get("message", ""))) - - except ExitError as error: - state.exit_code = error.exit_code - state.result = "error" - write_stderr_line(str(error)) - except Exception as error: - state.exit_code = 7 - state.result = "error" - write_stderr_line(str(error)) - finally: - if not state.duration_ms: - state.duration_ms = str(int((time.perf_counter() - started_at) * 1000)) - sys.stdout.write(render_protocol(state)) - - return state.exit_code +from eide_rebuild import main if __name__ == "__main__": diff --git a/runtime/python/eide_rebuild/__init__.py b/runtime/python/eide_rebuild/__init__.py new file mode 100644 index 0000000..616339c --- /dev/null +++ b/runtime/python/eide_rebuild/__init__.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +from .builder_params import generate_builder_params, write_builder_params +from .eide_model import EideModel, load_eide_model +from .executor import build_unify_builder_command, collect_output_files, rebuild_target, run_step +from .platform import current_platform, elapsed_ms, normalize_path, utc_now +from .project import ProjectInput, resolve_project_input +from .result_model import ( + RunResult, + StepResult, + TargetResult, + build_error_result, + build_run_result, + render_json_result, + write_run_result, +) +from .tools import ( + build_process_env, + check_unify_builder_runtime, + find_dotnet, + find_eide_extension_dir, + find_eide_tools_dir, + find_eide_utils_dir, + find_toolchain_root, + find_unify_builder, + run_doctor, +) + + +class ExitError(RuntimeError): + def __init__(self, exit_code: int, message: str, error_code: str) -> None: + super().__init__(message) + self.exit_code = exit_code + self.error_code = error_code + + +__all__ = [ + "EideModel", + "ExitError", + "ProjectInput", + "RunResult", + "StepResult", + "TargetResult", + "build_error_result", + "build_run_result", + "build_unify_builder_command", + "build_process_env", + "check_unify_builder_runtime", + "collect_output_files", + "current_platform", + "elapsed_ms", + "find_dotnet", + "find_eide_extension_dir", + "find_eide_tools_dir", + "find_eide_utils_dir", + "find_toolchain_root", + "find_unify_builder", + "generate_builder_params", + "load_eide_model", + "main", + "normalize_path", + "rebuild_target", + "render_json_result", + "resolve_project_input", + "run_doctor", + "run_step", + "utc_now", + "write_builder_params", + "write_run_result", +] + + +def _resolve_project_input_or_raise(input_path: str) -> ProjectInput: + try: + return resolve_project_input(input_path) + except RuntimeError as error: + raise ExitError(2, str(error), "MULTIPLE_WORKSPACES") from error + except FileNotFoundError as error: + missing_path = str(error) + error_code = "EIDE_YML_NOT_FOUND" if missing_path.replace("\\", "/").endswith("/.eide/eide.yml") else "PROJECT_PATH_NOT_FOUND" + raise ExitError(2, missing_path, error_code) from error + + +def _resolve_required_tools() -> tuple[str, str, str, str]: + try: + tool_paths = ( + find_dotnet(), + find_unify_builder(), + find_eide_tools_dir(), + find_toolchain_root(), + ) + runtime_check = check_unify_builder_runtime(tool_paths[0], tool_paths[1]) + if not runtime_check.get("ok", False): + raise ExitError(7, str(runtime_check.get("message") or "Unify builder runtime check failed."), "DOTNET_RUNTIME_MISSING") + return tool_paths + except FileNotFoundError as error: + raise ExitError(3, str(error), "TOOL_NOT_FOUND") from error + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + started_mark = time.perf_counter() + started_at = utc_now() + + try: + if arguments and arguments[0] == "doctor": + doctor_result = run_doctor() + sys.stdout.write(json.dumps(doctor_result, ensure_ascii=False, indent=2) + "\n") + return int(doctor_result["exitCode"]) + + if len(arguments) < 2 or arguments[0] != "rebuild": + raise ExitError(2, "Missing command or path.", "WORKSPACE_NOT_FOUND") + + project_input = _resolve_project_input_or_raise(arguments[1]) + model = load_eide_model(project_input.eide_yml_path) + if not model.target_names: + raise ExitError(6, "No targets found in .eide/eide.yml.", "TARGETS_NOT_FOUND") + + dotnet_path, unify_builder_path, eide_tools_dir, toolchain_root = _resolve_required_tools() + + target_results: list[TargetResult] = [] + transcript_parts: list[str] = [] + for index, target_name in enumerate(model.target_names, start=1): + target_result = rebuild_target( + project_root=project_input.project_root, + project_name=model.project_name, + target_name=target_name, + target_index=index, + target_total=len(model.target_names), + dotnet_path=dotnet_path, + unify_builder_path=unify_builder_path, + eide_tools_dir=eide_tools_dir, + toolchain_root=toolchain_root, + ) + target_results.append(target_result) + if target_result.transcript: + transcript_parts.append(target_result.transcript) + + finished_at = utc_now() + run_result = build_run_result( + workspace_path=project_input.workspace_path, + project_root=project_input.project_root, + project_name=model.project_name, + platform_name=current_platform(), + target_names=model.target_names, + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(started_mark), + targets=target_results, + transcript="\n".join(transcript_parts), + ) + write_run_result(project_input.project_root / "build" / "rebuild_result.json", run_result) + sys.stdout.write(render_json_result(run_result)) + return run_result.exit_code + except ExitError as error: + finished_at = utc_now() + run_result = build_error_result(error, started_at, finished_at, elapsed_ms(started_mark)) + sys.stdout.write(render_json_result(run_result)) + return error.exit_code + except Exception as error: + finished_at = utc_now() + wrapped_error = ExitError(7, str(error), "INTERNAL_ERROR") + run_result = build_error_result(wrapped_error, started_at, finished_at, elapsed_ms(started_mark)) + sys.stdout.write(render_json_result(run_result)) + return wrapped_error.exit_code diff --git a/runtime/python/eide_rebuild/builder_params.py b/runtime/python/eide_rebuild/builder_params.py new file mode 100644 index 0000000..9b9eb97 --- /dev/null +++ b/runtime/python/eide_rebuild/builder_params.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import copy +import json +import os +from pathlib import Path +from typing import Any + +import yaml + +from .eide_model import load_eide_model + + +SOURCE_EXTS = {".c", ".cpp", ".cc", ".cxx", ".s", ".a", ".o", ".lib", ".obj"} +_CPP_EXTS = {".cpp", ".cc", ".cxx"} +_ARM_TOOL_PREFIX = "arm-none-eabi-" + + +def _to_posix(path_value: Path | str) -> str: + return str(path_value).replace("\\", "/") + + +def _join_posix(root_dir: Path, path_value: str) -> str: + path = Path(path_value) + if path.is_absolute(): + return _to_posix(path) + return _to_posix(root_dir / path) + + +def _is_excluded(vpath: str, exclude_list: list[Any]) -> bool: + normalized = _to_posix(vpath).strip("/") + for entry in exclude_list: + excluded = _to_posix(str(entry)).strip("/") + if normalized == excluded or normalized.startswith(f"{excluded}/"): + return True + return False + + +def collect_sources(folder: dict[str, Any], exclude_list: list[Any], parent_vpath: str = "") -> list[str]: + sources: list[str] = [] + + for file_entry in folder.get("files") or []: + file_path = _to_posix(str((file_entry or {}).get("path", ""))) + if Path(file_path).suffix.lower() not in SOURCE_EXTS: + continue + file_name = str((file_entry or {}).get("name") or Path(file_path).name) + file_vpath = f"{parent_vpath}/{file_name}" + if _is_excluded(file_vpath, exclude_list): + continue + sources.append(file_path) + + for child in folder.get("folders") or []: + child_folder = child or {} + child_name = str(child_folder.get("name") or "") + child_vpath = f"{parent_vpath}/{child_name}" if child_name else parent_vpath + if _is_excluded(child_vpath, exclude_list): + continue + sources.extend(collect_sources(child_folder, exclude_list, child_vpath)) + + return sorted(sources) + + +def _pre_handle_options( + options: dict[str, Any], + source_list: list[str], + cpu_type: str, + fp_hardware: str, + arch_extensions: str, + scatter_path: str, + root_dir: Path, +) -> None: + global_options = options.setdefault("global", {}) + linker_options = options.setdefault("linker", {}) + + global_options["toolPrefix"] = _ARM_TOOL_PREFIX + linker_options["$toolName"] = "g++" if any(Path(source).suffix.lower() in _CPP_EXTS for source in source_list) else "gcc" + + fpu_suffix = {"single": "-sp", "double": "-dp"}.get(str(fp_hardware or "").lower(), "") + cpu_fpu_id = f"{str(cpu_type).lower()}{fpu_suffix}" + global_options["microcontroller-cpu"] = cpu_fpu_id + global_options["microcontroller-fpu"] = cpu_fpu_id + global_options["microcontroller-float"] = cpu_fpu_id + global_options["$arch-extensions"] = arch_extensions or "" + global_options["$clang-arch-extensions"] = "" + global_options["$armlink-arch-extensions"] = "" + + if str(linker_options.get("output-format") or "").lower() == "lib": + linker_options["$use"] = "linker-lib" + linker_options.pop("link-scatter", None) + elif scatter_path: + linker_options["link-scatter"] = [_join_posix(root_dir, scatter_path)] + + before_build_tasks = options.get("beforeBuildTasks") + after_build_tasks = options.get("afterBuildTasks") + options["beforeBuildTasks"] = list(before_build_tasks) if isinstance(before_build_tasks, list) else [] + options["afterBuildTasks"] = list(after_build_tasks) if isinstance(after_build_tasks, list) else [] + + +def _build_env(project_name: str, target: str, root_dir: Path, toolchain_loc: str) -> dict[str, str]: + root = _to_posix(root_dir) + is_windows = os.name == "nt" + dir_sep = "\\" if is_windows else "/" + path_sep = ";" if is_windows else ":" + out_dir = f"{root}/build/{target}" + return { + "workspaceFolder": root, + "workspaceFolderBasename": os.path.basename(root), + "OutDir": out_dir, + "OutDirRoot": "build", + "OutDirBase": f"build/{target}", + "ProjectName": project_name, + "ConfigName": target, + "ProjectRoot": root, + "ExecutableName": f"{root}/build/{target}/{project_name}", + "ChipPackDir": "", + "ChipName": "", + "SYS_Platform": "windows" if is_windows else "linux", + "SYS_DirSep": dir_sep, + "SYS_DirSeparator": dir_sep, + "SYS_PathSep": path_sep, + "SYS_PathSeparator": path_sep, + "SYS_EOL": "\n", + "ToolchainRoot": toolchain_loc, + } + + +def _load_source_params(eide_dir: Path, target: str) -> dict[str, Any]: + files_options_path = eide_dir / "files.options.yml" + if not files_options_path.exists(): + return {} + with files_options_path.open("r", encoding="utf-8") as stream: + files_opts = yaml.safe_load(stream) or {} + source_params = (((files_opts.get("options") or {}).get(target) or {}).get("files") or {}) + if isinstance(source_params, dict): + return source_params + return {} + + +def generate_builder_params(project_root: Path, target: str, eide_tools_dir: str, toolchain_root: str) -> dict[str, Any]: + root_dir = Path(project_root).resolve() + eide_dir = root_dir / ".eide" + model = load_eide_model(eide_dir / "eide.yml") + target_data = (model.payload.get("targets") or {})[target] + toolchain = str(target_data.get("toolchain") or "GCC") + toolchain_cfg = ((target_data.get("toolchainConfigMap") or {}).get(toolchain) or {}) + cpp_attrs = target_data.get("cppPreprocessAttrs") or {} + relative_sources = collect_sources(model.payload.get("virtualFolder") or {}, list(target_data.get("excludeList") or [])) + source_list = relative_sources + options = copy.deepcopy(toolchain_cfg.get("options") or {}) + + _pre_handle_options( + options, + source_list, + str(toolchain_cfg.get("cpuType") or ""), + str(toolchain_cfg.get("floatingPointHardware") or ""), + str(toolchain_cfg.get("archExtensions") or ""), + str(toolchain_cfg.get("scatterFilePath") or ""), + root_dir, + ) + + dump_path = f"build/{target}" + return { + "name": model.project_name, + "target": target, + "toolchain": toolchain, + "toolchainLocation": _to_posix(toolchain_root), + "toolchainCfgFile": _to_posix(f"{eide_tools_dir.rstrip('/').rstrip('\\\\')}/arm.gcc.model.json"), + "buildMode": "fast|multhread", + "showRepathOnLog": True, + "threadNum": os.cpu_count() or 4, + "rootDir": _to_posix(root_dir), + "dumpPath": dump_path, + "outDir": dump_path, + "incDirs": list(cpp_attrs.get("incList") or []), + "libDirs": list(cpp_attrs.get("libList") or []), + "defines": list(cpp_attrs.get("defineList") or []), + "sourceList": source_list, + "alwaysInBuildSources": [], + "sourceParams": _load_source_params(eide_dir, target), + "options": options, + "env": _build_env(model.project_name, target, root_dir, toolchain_root), + "sysPaths": [], + } + + +def write_builder_params(project_root: Path, target: str, params: dict[str, Any]) -> Path: + output_path = Path(project_root).resolve() / "build" / target / "builder.params" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(params, stream, indent=4, ensure_ascii=False) + return output_path diff --git a/runtime/python/eide_rebuild/eide_model.py b/runtime/python/eide_rebuild/eide_model.py new file mode 100644 index 0000000..33d201e --- /dev/null +++ b/runtime/python/eide_rebuild/eide_model.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +@dataclass +class EideModel: + project_name: str + target_names: list[str] + payload: dict[str, Any] + + +def load_eide_model(eide_yml_path: Path) -> EideModel: + with eide_yml_path.open("r", encoding="utf-8") as stream: + payload = yaml.safe_load(stream) or {} + targets = list((payload.get("targets") or {}).keys()) + return EideModel(project_name=str(payload.get("name", "")), target_names=targets, payload=payload) diff --git a/runtime/python/eide_rebuild/executor.py b/runtime/python/eide_rebuild/executor.py new file mode 100644 index 0000000..7f39bb7 --- /dev/null +++ b/runtime/python/eide_rebuild/executor.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import locale +import os +import re +import subprocess +import time +from pathlib import Path +from typing import Any + +from .builder_params import generate_builder_params, write_builder_params +from .platform import elapsed_ms, normalize_path, utc_now +from .result_model import StepResult, TargetResult +from .tools import build_process_env, resolve_unify_builder_dll + + +def run_step( + kind: str, + name: str, + command: list[str] | str, + cwd: Path, + env: dict[str, str] | None = None, +) -> StepResult: + start_mark = time.perf_counter() + started_at = utc_now() + run_kwargs: dict[str, Any] = { + "cwd": cwd, + "capture_output": True, + "text": True, + } + if env: + merged_env = os.environ.copy() + merged_env.update(env) + run_kwargs["env"] = merged_env + if isinstance(command, str): + run_kwargs["shell"] = True + completed = subprocess.run(command, **run_kwargs) + finished_at = utc_now() + exit_code = int(completed.returncode) + return StepResult( + kind=kind, + name=name, + ok=exit_code == 0, + exit_code=exit_code, + error_code="OK" if exit_code == 0 else "STEP_FAILED", + message="" if exit_code == 0 else f"{name} failed with exit code {exit_code}.", + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(start_mark), + stdout=completed.stdout or "", + stderr=completed.stderr or "", + command=[command] if isinstance(command, str) else [str(part) for part in command], + cwd=normalize_path(cwd.resolve()), + ) + + +def build_unify_builder_command(dotnet_path: str, unify_builder_path: str, builder_params_path: str) -> list[str]: + unify_builder_dll = resolve_unify_builder_dll(unify_builder_path) + return [ + dotnet_path, + "exec", + "--roll-forward", + "Major", + unify_builder_dll, + "-p", + builder_params_path, + ] + + +def _make_step_result( + *, + kind: str, + name: str, + started_at: str, + finished_at: str, + duration_ms: int, + ok: bool, + error_code: str, + message: str = "", + stdout: str = "", + stderr: str = "", + command: list[str] | None = None, + cwd: Path | None = None, + exit_code: int = 0, +) -> StepResult: + return StepResult( + kind=kind, + name=name, + ok=ok, + exit_code=exit_code, + error_code=error_code, + message=message, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + stdout=stdout, + stderr=stderr, + command=list(command or []), + cwd=normalize_path(cwd.resolve()) if cwd is not None else "", + ) + + +def _read_text_file(path_value: Path) -> str: + payload = path_value.read_bytes() + try: + text = payload.decode("utf-8", errors="strict") + except UnicodeDecodeError: + text = payload.decode(locale.getpreferredencoding(False), errors="replace") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def collect_output_files(project_root: Path, target_name: str) -> list[dict[str, object]]: + build_dir = project_root / "build" / target_name + if not build_dir.exists(): + return [] + + suffix_map = { + ".bin": "bin", + ".hex": "hex", + ".a": "archive", + ".map": "map", + } + artifacts: list[dict[str, object]] = [] + for candidate in sorted(build_dir.iterdir()): + artifact_kind = suffix_map.get(candidate.suffix.lower()) + if artifact_kind is None or not candidate.is_file(): + continue + artifacts.append( + { + "path": normalize_path(candidate.resolve()), + "kind": artifact_kind, + "size": candidate.stat().st_size, + } + ) + return artifacts + + +def _build_transcript(steps: list[StepResult]) -> str: + parts: list[str] = [] + for step in steps: + if step.stdout: + parts.append(step.stdout.rstrip("\n")) + if step.stderr: + parts.append(step.stderr.rstrip("\n")) + return "\n".join(part for part in parts if part) + + +def _size_to_bytes(size_value: str, unit: str) -> int: + multipliers = { + "B": 1, + "KB": 1024, + "MB": 1024 * 1024, + } + return int(float(size_value) * multipliers[unit.upper()]) + + +def _parse_memory_regions(stdout: str) -> list[dict[str, object]]: + pattern = re.compile( + r"^\s*(?P[A-Za-z0-9_]+):\s+" + r"(?P\d+(?:\.\d+)?)\s+(?PB|KB|MB)\s+" + r"(?P\d+(?:\.\d+)?)\s+(?PB|KB|MB)\s+" + r"(?P\d+(?:\.\d+)?)%$" + ) + regions: list[dict[str, object]] = [] + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + match = pattern.match(raw_line.strip()) + if not match: + continue + regions.append( + { + "name": match.group("name"), + "used": _size_to_bytes(match.group("used"), match.group("used_unit")), + "total": _size_to_bytes(match.group("total"), match.group("total_unit")), + "percent": float(match.group("percent")), + "unit": "B", + } + ) + return regions + + +def _parse_source_stats(stdout: str) -> dict[str, int]: + stats_pattern = re.compile( + r"\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|" + ) + jobs_pattern = re.compile(r"start compiling \(jobs:\s*(?P\d+)\)") + result: dict[str, int] = {} + + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw_line.strip() + match = stats_pattern.search(line) + if match: + result.update( + { + "cFiles": int(match.group("c")), + "cppFiles": int(match.group("cpp")), + "asmFiles": int(match.group("asm")), + "libObjFiles": int(match.group("libobj")), + "totalFiles": int(match.group("total")), + } + ) + continue + match = jobs_pattern.search(line) + if match: + result["jobs"] = int(match.group("jobs")) + + return result + + +def _parse_embedded_task_failures(stdout: str) -> list[dict[str, str]]: + task_pattern = re.compile(r"^>>\s*(?P.+?)\s+\[(?Pdone|failed)\]\s*$", re.IGNORECASE) + current_section = "build-task" + failures: list[dict[str, str]] = [] + + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw_line.strip() + lowered = line.lower() + if lowered == "[ info ] pre-build tasks ...": + current_section = "pre-build-task" + continue + if lowered == "[ info ] post-build tasks ...": + current_section = "post-build-task" + continue + if lowered == "[ info ] start outputting files ...": + current_section = "output-task" + continue + + match = task_pattern.match(line) + if match and match.group("status").lower() == "failed": + failures.append( + { + "kind": current_section, + "name": match.group("name").strip(), + } + ) + + return failures + + +def rebuild_target( + *, + project_root: Path, + project_name: str, + target_name: str, + target_index: int, + target_total: int, + dotnet_path: str, + unify_builder_path: str, + eide_tools_dir: str, + toolchain_root: str, +) -> TargetResult: + start_mark = time.perf_counter() + started_at = utc_now() + steps: list[StepResult] = [] + compiler_log = "" + compiler_log_path = project_root / "build" / target_name / "compiler.log" + builder_params_path = project_root / "build" / target_name / "builder.params" + stack_report_json_path = project_root / "build" / target_name / "stack_report.json" + stack_report_html_path = project_root / "build" / target_name / "stack_report.html" + builder_params_summary: dict[str, object] = {} + memory: list[dict[str, object]] = [] + source_stats: dict[str, int] = {} + error_code = "OK" + message = "" + exit_code = 0 + + try: + step_started_mark = time.perf_counter() + step_started_at = utc_now() + params = generate_builder_params(project_root, target_name, eide_tools_dir, toolchain_root) + builder_params_path = write_builder_params(project_root, target_name, params) + step_finished_at = utc_now() + builder_params_summary = { + "toolchain": params.get("toolchain", ""), + "threadNum": params.get("threadNum", 0), + "sourceCount": len(list(params.get("sourceList") or [])), + } + hook_env = {str(key): str(value) for key, value in dict(params.get("env") or {}).items()} + process_env = build_process_env(hook_env, toolchain_root) + steps.append( + _make_step_result( + kind="generate-builder-params", + name=f"generate {target_name} builder.params", + started_at=step_started_at, + finished_at=step_finished_at, + duration_ms=elapsed_ms(step_started_mark), + ok=True, + error_code="OK", + stdout=f"Generated: {normalize_path(builder_params_path.resolve())}\n", + ) + ) + + build_step = run_step( + kind="unify-builder", + name=f"build {target_name}", + command=build_unify_builder_command( + dotnet_path=dotnet_path, + unify_builder_path=unify_builder_path, + builder_params_path=normalize_path(builder_params_path.resolve()), + ), + cwd=project_root, + env=process_env, + ) + steps.append(build_step) + + if not build_step.ok: + error_code = "UNIFY_BUILDER_FAILED" + message = build_step.message or f"{target_name} build failed." + exit_code = 6 + elif compiler_log_path.exists(): + compiler_log = _read_text_file(compiler_log_path) + memory = _parse_memory_regions(build_step.stdout) + source_stats = _parse_source_stats(build_step.stdout) + embedded_failures = _parse_embedded_task_failures(build_step.stdout) + if embedded_failures: + first_failure = embedded_failures[0] + error_code_map = { + "pre-build-task": "PRE_BUILD_TASK_FAILED", + "post-build-task": "POST_BUILD_TASK_FAILED", + "output-task": "OUTPUT_TASK_FAILED", + } + error_code = error_code_map.get(first_failure["kind"], "BUILD_TASK_FAILED") + message = f"{first_failure['name']} failed inside unify_builder." + exit_code = 4 + else: + error_code = "COMPILER_LOG_MISSING" + message = f"compiler.log not found: {normalize_path(compiler_log_path)}" + exit_code = 8 + except Exception as error: + if exit_code == 0: + error_code = "BUILDER_PARAMS_GENERATION_FAILED" + message = str(error) + exit_code = 4 + + finished_at = utc_now() + artifacts = collect_output_files(project_root, target_name) + transcript = _build_transcript(steps) + return TargetResult( + name=target_name, + index=target_index, + total=target_total, + ok=exit_code == 0, + exit_code=exit_code, + error_code=error_code, + message=message, + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(start_mark), + builder_params_path=normalize_path(builder_params_path.resolve()), + builder_params_summary=builder_params_summary, + compiler_log_path=normalize_path(compiler_log_path.resolve()), + compiler_log=compiler_log, + stack_report_json_path=normalize_path(stack_report_json_path.resolve()) if stack_report_json_path.exists() else "", + stack_report_html_path=normalize_path(stack_report_html_path.resolve()) if stack_report_html_path.exists() else "", + source_stats=source_stats, + memory=memory, + artifacts=artifacts, + transcript=transcript, + steps=steps, + ) diff --git a/runtime/python/eide_rebuild/platform.py b/runtime/python/eide_rebuild/platform.py new file mode 100644 index 0000000..fbf9131 --- /dev/null +++ b/runtime/python/eide_rebuild/platform.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +import time +from datetime import datetime, timezone +from pathlib import Path + + +def normalize_path(path_value: str | Path) -> str: + return str(Path(path_value)).replace("\\", "/") + + +def current_platform() -> str: + return "windows" if os.name == "nt" else "linux" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def elapsed_ms(start_mark: float) -> int: + return int((time.perf_counter() - start_mark) * 1000) diff --git a/runtime/python/eide_rebuild/project.py b/runtime/python/eide_rebuild/project.py new file mode 100644 index 0000000..00af066 --- /dev/null +++ b/runtime/python/eide_rebuild/project.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class ProjectInput: + project_root: Path + workspace_path: str + eide_yml_path: Path + + +def resolve_project_input(input_path: str) -> ProjectInput: + path_obj = Path(input_path).expanduser().resolve() + if not path_obj.exists(): + raise FileNotFoundError(input_path) + + if path_obj.is_file() and path_obj.suffix.lower() == ".code-workspace": + project_root = path_obj.parent + eide_yml = project_root / ".eide" / "eide.yml" + if not eide_yml.exists(): + raise FileNotFoundError(str(eide_yml)) + return ProjectInput( + project_root=project_root, + workspace_path=str(path_obj).replace("\\", "/"), + eide_yml_path=eide_yml, + ) + + if path_obj.is_dir(): + workspace_files = sorted(path_obj.glob("*.code-workspace")) + if len(workspace_files) > 1: + raise RuntimeError(f"Expected one workspace file in {path_obj}") + eide_yml = path_obj / ".eide" / "eide.yml" + if eide_yml.exists(): + workspace_path = str(workspace_files[0].resolve()).replace("\\", "/") if workspace_files else "" + return ProjectInput( + project_root=path_obj, + workspace_path=workspace_path, + eide_yml_path=eide_yml, + ) + if len(workspace_files) == 1: + raise FileNotFoundError(str(eide_yml)) + + raise FileNotFoundError(input_path) diff --git a/runtime/python/eide_rebuild/result_model.py b/runtime/python/eide_rebuild/result_model.py new file mode 100644 index 0000000..2932233 --- /dev/null +++ b/runtime/python/eide_rebuild/result_model.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field, is_dataclass +from pathlib import Path +from typing import Any + + +_KEY_MAP = { + "schema_version": "schemaVersion", + "exit_code": "exitCode", + "error_code": "errorCode", + "workspace_path": "workspacePath", + "project_root": "projectRoot", + "project_name": "projectName", + "started_at": "startedAt", + "finished_at": "finishedAt", + "duration_ms": "durationMs", + "target_names": "targetNames", + "source_stats": "sourceStats", + "builder_params_path": "builderParamsPath", + "builder_params_summary": "builderParamsSummary", + "compiler_log_path": "compilerLogPath", + "compiler_log": "compilerLog", + "stack_report_json_path": "stackReportJsonPath", + "stack_report_html_path": "stackReportHtmlPath", +} + + +@dataclass +class StepResult: + kind: str = "" + name: str = "" + ok: bool = False + exit_code: int = 0 + error_code: str = "" + message: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + stdout: str = "" + stderr: str = "" + command: list[str] = field(default_factory=list) + cwd: str = "" + + +@dataclass +class TargetResult: + name: str = "" + index: int = 0 + total: int = 0 + ok: bool = False + exit_code: int = 0 + error_code: str = "" + message: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + source_stats: dict[str, Any] = field(default_factory=dict) + builder_params_path: str = "" + builder_params_summary: dict[str, Any] = field(default_factory=dict) + compiler_log_path: str = "" + compiler_log: str = "" + stack_report_json_path: str = "" + stack_report_html_path: str = "" + transcript: str = "" + memory: list[dict[str, Any]] = field(default_factory=list) + artifacts: list[dict[str, Any]] = field(default_factory=list) + steps: list[StepResult] = field(default_factory=list) + + +@dataclass +class RunResult: + schema_version: str = "1" + ok: bool = False + exit_code: int = 7 + error_code: str = "INTERNAL_ERROR" + message: str = "" + mode: str = "rebuild-all" + platform: str = "" + workspace_path: str = "" + project_root: str = "" + project_name: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + summary: dict[str, Any] = field(default_factory=dict) + target_names: list[str] = field(default_factory=list) + transcript: str = "" + targets: list[TargetResult] = field(default_factory=list) + + +def _to_json_value(value: Any) -> Any: + if is_dataclass(value): + return _to_json_value(asdict(value)) + if isinstance(value, dict): + return {_KEY_MAP.get(key, key): _to_json_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_json_value(item) for item in value] + return value + + +def render_json_result(result: RunResult) -> str: + return json.dumps(_to_json_value(result), ensure_ascii=False, indent=2) + "\n" + + +def _normalize_path(path_value: Path | str) -> str: + return str(Path(path_value).resolve()).replace("\\", "/") + + +def _current_platform() -> str: + import os + + return "windows" if os.name == "nt" else "linux" + + +def build_run_result( + workspace_path: str, + project_root: Path | str, + project_name: str, + platform_name: str, + target_names: list[str], + started_at: str, + finished_at: str, + duration_ms: int, + targets: list[TargetResult], + transcript: str, +) -> RunResult: + passed = sum(1 for target in targets if target.ok) + failed = len(targets) - passed + return RunResult( + ok=failed == 0, + exit_code=0 if failed == 0 else 6, + error_code="OK" if failed == 0 else "BUILD_FAILED", + message="" if failed == 0 else f"{failed} target(s) failed.", + mode="rebuild-all", + platform=platform_name, + workspace_path=workspace_path, + project_root=_normalize_path(project_root), + project_name=project_name, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + summary={"discovered": len(target_names), "passed": passed, "failed": failed}, + target_names=target_names, + transcript=transcript, + targets=targets, + ) + + +def build_error_result(error: Exception, started_at: str, finished_at: str, duration_ms: int) -> RunResult: + error_code = getattr(error, "error_code", "INTERNAL_ERROR") + exit_code = getattr(error, "exit_code", 7) + return RunResult( + ok=False, + exit_code=exit_code, + error_code=error_code, + message=str(error), + mode="rebuild-all", + platform=_current_platform(), + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + summary={"discovered": 0, "passed": 0, "failed": 0}, + ) + + +def write_run_result(output_path: Path | str, result: RunResult) -> None: + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_json_result(result), encoding="utf-8", newline="\n") diff --git a/runtime/python/eide_rebuild/tools.py b/runtime/python/eide_rebuild/tools.py new file mode 100644 index 0000000..533374c --- /dev/null +++ b/runtime/python/eide_rebuild/tools.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +from .platform import current_platform, normalize_path + + +def _resolve_existing_path(path_value: str, expect_dir: bool = False) -> str: + candidate = Path(path_value).expanduser() + if expect_dir: + if candidate.is_dir(): + return normalize_path(candidate.resolve()) + elif candidate.exists(): + return normalize_path(candidate.resolve()) + raise FileNotFoundError(str(candidate)) + + +def _path_if_dir(path_value: Path) -> str | None: + return normalize_path(path_value.resolve()) if path_value.is_dir() else None + + +def _version_key(path_value: Path) -> tuple[int, ...]: + match = re.search(r"(\d+(?:\.\d+)+)", path_value.name) + if not match: + return (0,) + return tuple(int(part) for part in match.group(1).split(".")) + + +def _iter_existing_dirs(paths: list[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[str] = set() + for path in paths: + try: + resolved = path.expanduser().resolve() + except OSError: + continue + normalized = normalize_path(resolved) + if normalized in seen or not resolved.is_dir(): + continue + seen.add(normalized) + result.append(resolved) + return result + + +def _extension_roots() -> list[Path]: + roots: list[Path] = [] + override = os.environ.get("EIDE_REBUILD_VSCODE_EXTENSIONS_ROOT") + if override: + roots.append(Path(override)) + home_override = os.environ.get("EIDE_REBUILD_HOME") + if home_override: + roots.append(Path(home_override) / ".vscode" / "extensions") + roots.append(Path.home() / ".vscode" / "extensions") + return _iter_existing_dirs(roots) + + +def find_eide_extension_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_EXTENSION_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + candidates: list[Path] = [] + for root in _extension_roots(): + candidates.extend(path for path in root.glob("cl.eide-*") if path.is_dir()) + if not candidates: + raise FileNotFoundError("EIDE extension directory") + + best = sorted( + candidates, + key=lambda path: (_version_key(path), path.stat().st_mtime), + reverse=True, + )[0] + return normalize_path(best.resolve()) + + +def _candidate_model_dirs(base_dir: Path) -> list[Path]: + return [ + base_dir, + base_dir / "models", + base_dir / "res" / "data" / "models", + base_dir / "data" / "models", + ] + + +def _resolve_model_dir(base_dir: Path) -> str | None: + for candidate in _candidate_model_dirs(base_dir): + if (candidate / "arm.gcc.model.json").exists(): + return normalize_path(candidate.resolve()) + return None + + +def find_dotnet() -> str: + override = os.environ.get("EIDE_REBUILD_DOTNET") or os.environ.get("DOTNET_HOST_PATH") + if override: + return _resolve_existing_path(override) + command_path = shutil.which("dotnet") + if command_path: + return normalize_path(Path(command_path).resolve()) + raise FileNotFoundError("dotnet") + + +def find_eide_tools_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_TOOLS_DIR") or os.environ.get("EIDE_TOOLS_DIR") + if override: + resolved = _resolve_model_dir(Path(override).expanduser()) + if resolved: + return resolved + raise FileNotFoundError(str(Path(override).expanduser())) + + extension_dir = Path(find_eide_extension_dir()) + resolved = _resolve_model_dir(extension_dir) + if resolved: + return resolved + raise FileNotFoundError("EIDE tools directory") + + +def _platform_tool_dir(extension_dir: Path) -> Path: + platform_name = current_platform() + platform_folder = "win32" if platform_name == "windows" else platform_name + return extension_dir / "res" / "tools" / platform_folder + + +def find_unify_builder() -> str: + override = os.environ.get("EIDE_REBUILD_UNIFY_BUILDER") + if override: + return _resolve_existing_path(override) + + tools_override = os.environ.get("EIDE_REBUILD_EIDE_TOOLS_DIR") or os.environ.get("EIDE_TOOLS_DIR") + if tools_override: + direct_candidate = Path(tools_override).expanduser() / "unify_builder.dll" + if direct_candidate.exists(): + return normalize_path(direct_candidate.resolve()) + + unify_root = _platform_tool_dir(Path(find_eide_extension_dir())) / "unify_builder" + candidate_names = ["unify_builder.exe", "unify_builder.dll"] if current_platform() == "windows" else ["unify_builder.dll"] + for candidate_name in candidate_names: + candidate = unify_root / candidate_name + if candidate.exists(): + return normalize_path(candidate.resolve()) + raise FileNotFoundError("unify_builder.dll") + + +def resolve_unify_builder_dll(unify_builder_path: str) -> str: + path_obj = Path(unify_builder_path) + if path_obj.suffix.lower() == ".dll": + return normalize_path(path_obj.resolve()) + + sibling_dll = path_obj.with_suffix(".dll") + if sibling_dll.exists(): + return normalize_path(sibling_dll.resolve()) + + raise FileNotFoundError(str(sibling_dll)) + + +def find_eide_utils_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_UTILS_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + utils_dir = _platform_tool_dir(Path(find_eide_extension_dir())) / "utils" + resolved = _path_if_dir(utils_dir) + if resolved: + return resolved + raise FileNotFoundError("EIDE utils directory") + + +def _toolchain_search_roots() -> list[Path]: + roots: list[Path] = [] + override = os.environ.get("EIDE_REBUILD_TOOLS_ROOT") + if override: + roots.append(Path(override)) + home_override = os.environ.get("EIDE_REBUILD_HOME") + if home_override: + roots.append(Path(home_override) / ".eide" / "tools") + roots.append(Path.home() / ".eide" / "tools") + return _iter_existing_dirs(roots) + + +def find_toolchain_root() -> str: + override = os.environ.get("EIDE_REBUILD_TOOLCHAIN_ROOT") or os.environ.get("COMPILER_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + candidates: list[Path] = [] + for root in _toolchain_search_roots(): + for gcc_path in root.glob("**/bin/arm-none-eabi-gcc.exe"): + candidates.append(gcc_path.parent.parent) + for gcc_path in root.glob("**/bin/arm-none-eabi-gcc"): + candidates.append(gcc_path.parent.parent) + if not candidates: + raise FileNotFoundError("toolchain root") + + best = sorted( + candidates, + key=lambda path: (_version_key(path), path.stat().st_mtime), + reverse=True, + )[0] + return normalize_path(best.resolve()) + + +def build_process_env(extra_env: dict[str, str] | None, toolchain_root: str) -> dict[str, str]: + env = os.environ.copy() + if extra_env: + env.update({str(key): str(value) for key, value in extra_env.items()}) + + path_parts: list[str] = [] + try: + path_parts.append(str(Path(find_eide_utils_dir()).resolve())) + except FileNotFoundError: + pass + + toolchain_bin = Path(toolchain_root) / "bin" + if toolchain_bin.is_dir(): + path_parts.append(str(toolchain_bin.resolve())) + + existing_path = env.get("PATH", "") + if existing_path: + path_parts.append(existing_path) + env["PATH"] = os.pathsep.join(path_parts) + return env + + +def check_unify_builder_runtime(dotnet_path: str, unify_builder_path: str) -> dict[str, object]: + unify_builder_dll = resolve_unify_builder_dll(unify_builder_path) + runtime_config = Path(unify_builder_dll).with_suffix(".runtimeconfig.json") + framework_name = "" + framework_version = "" + if runtime_config.exists(): + payload = json.loads(runtime_config.read_text(encoding="utf-8")) + runtime_options = payload.get("runtimeOptions") or {} + framework = runtime_options.get("framework") or {} + framework_name = str(framework.get("name") or "") + framework_version = str(framework.get("version") or "") + + completed = subprocess.run( + [dotnet_path, "exec", "--roll-forward", "Major", unify_builder_dll, "-v"], + capture_output=True, + text=True, + check=False, + ) + ok = completed.returncode == 0 + message = "" + if not ok: + message = completed.stderr.strip() or completed.stdout.strip() or "Failed to start unify_builder." + + installed_versions: list[str] = [] + runtimes = subprocess.run( + [dotnet_path, "--list-runtimes"], + capture_output=True, + text=True, + check=False, + ) + if runtimes.returncode == 0 and framework_name: + for raw_line in runtimes.stdout.splitlines(): + match = re.match(r"^(?P\S+)\s+(?P\d+\.\d+\.\d+)", raw_line.strip()) + if not match or match.group("name") != framework_name: + continue + installed_versions.append(match.group("version")) + + return { + "ok": ok, + "requiredFramework": framework_name, + "requiredVersion": framework_version, + "installedVersions": installed_versions, + "message": message, + "launchCommand": [dotnet_path, "exec", "--roll-forward", "Major", unify_builder_dll, "-v"], + } + + +def run_doctor() -> dict[str, object]: + tools: dict[str, str] = {} + errors: list[str] = [] + runtime_info: dict[str, object] = {"ok": True} + + checks = { + "dotnet": find_dotnet, + "eideExtensionDir": find_eide_extension_dir, + "eideToolsDir": find_eide_tools_dir, + "eideUtilsDir": find_eide_utils_dir, + "unifyBuilder": find_unify_builder, + "toolchainRoot": find_toolchain_root, + } + + for name, getter in checks.items(): + try: + tools[name] = getter() + except FileNotFoundError as error: + errors.append(f"{name}: {error}") + + if "dotnet" in tools and "unifyBuilder" in tools: + runtime_info = check_unify_builder_runtime(tools["dotnet"], tools["unifyBuilder"]) + if not runtime_info.get("ok", False): + errors.append(str(runtime_info.get("message") or "Unify builder runtime check failed.")) + + ok = not errors + return { + "ok": ok, + "exitCode": 0 if ok else 3, + "errorCode": "OK" if ok else "TOOL_NOT_FOUND", + "message": "" if ok else "; ".join(errors), + "platform": current_platform(), + "tools": tools, + "runtime": runtime_info, + } diff --git a/runtime/tests/test_eide_rebuild.py b/runtime/tests/test_eide_rebuild.py index f4513c6..d1a5bb8 100644 --- a/runtime/tests/test_eide_rebuild.py +++ b/runtime/tests/test_eide_rebuild.py @@ -1,7 +1,10 @@ from __future__ import annotations import io +import json +import os import shutil +import subprocess import sys import unittest import uuid @@ -17,6 +20,7 @@ sys.path.insert(0, str(RUNTIME_PYTHON)) import eide_rebuild +from eide_rebuild import builder_params as builder_params_module @contextmanager @@ -30,110 +34,1257 @@ def make_temp_dir() -> str: shutil.rmtree(temp_dir, ignore_errors=True) -class ResolveWorkspacePathTests(unittest.TestCase): - def test_accepts_workspace_file(self) -> None: +class ProjectResolutionTests(unittest.TestCase): + def test_accepts_workspace_file_when_eide_yml_exists(self) -> None: with make_temp_dir() as temp_dir: - workspace_file = Path(temp_dir) / "demo.code-workspace" + project_dir = Path(temp_dir) + workspace_file = project_dir / "demo.code-workspace" workspace_file.write_text("{}", encoding="utf-8") + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") - result = eide_rebuild.resolve_workspace_path(str(workspace_file)) + result = eide_rebuild.resolve_project_input(str(workspace_file)) - self.assertEqual(result, workspace_file.resolve()) + self.assertEqual(result.project_root, project_dir.resolve()) + self.assertEqual(result.workspace_path, workspace_file.resolve().as_posix()) + self.assertEqual(result.eide_yml_path, (eide_dir / "eide.yml").resolve()) - def test_resolves_single_workspace_in_directory(self) -> None: + def test_accepts_project_root_without_workspace_file_when_eide_yml_exists(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") + + result = eide_rebuild.resolve_project_input(str(project_dir)) + + self.assertEqual(result.project_root, project_dir.resolve()) + self.assertEqual(result.workspace_path, "") + self.assertEqual(result.eide_yml_path, (eide_dir / "eide.yml").resolve()) + + def test_accepts_project_root_with_single_workspace_file(self) -> None: with make_temp_dir() as temp_dir: project_dir = Path(temp_dir) workspace_file = project_dir / "demo.code-workspace" workspace_file.write_text("{}", encoding="utf-8") + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") - result = eide_rebuild.resolve_workspace_path(str(project_dir)) + result = eide_rebuild.resolve_project_input(str(project_dir)) - self.assertEqual(result, workspace_file.resolve()) + self.assertEqual(result.workspace_path, workspace_file.resolve().as_posix()) - def test_rejects_multiple_workspace_files(self) -> None: + def test_rejects_multiple_workspace_files_for_project_root(self) -> None: with make_temp_dir() as temp_dir: project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") (project_dir / "a.code-workspace").write_text("{}", encoding="utf-8") (project_dir / "b.code-workspace").write_text("{}", encoding="utf-8") - with self.assertRaises(eide_rebuild.ExitError) as error: - eide_rebuild.resolve_workspace_path(str(project_dir)) + with self.assertRaises(RuntimeError): + eide_rebuild.resolve_project_input(str(project_dir)) - self.assertEqual(error.exception.exit_code, 2) +class EideModelTests(unittest.TestCase): + def test_discovers_all_targets_from_eide_yml(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: {toolchain: GCC, toolchainConfigMap: {GCC: {options: {}, cpuType: Cortex-M33, scatterFilePath: linker.ld}}} + Release: {toolchain: GCC, toolchainConfigMap: {GCC: {options: {}, cpuType: Cortex-M33, scatterFilePath: linker.ld}}} +''', + encoding="utf-8", + ) -class ProtocolTests(unittest.TestCase): - def test_render_protocol_appends_newline_after_log(self) -> None: - state = eide_rebuild.ProtocolState( - workspace=r"C:\work\demo\project.code-workspace", - target="Debug", - log_path=r"C:\work\demo\build\Debug\compiler.log", - result="failure", - duration_ms="321", - exit_code=6, - compiler_log="line-1", - ) + model = eide_rebuild.load_eide_model(project_dir / ".eide" / "eide.yml") - rendered = eide_rebuild.render_protocol(state) + self.assertEqual(model.project_name, "demo") + self.assertEqual(model.target_names, ["Debug", "Release"]) - self.assertIn("line-1\n[EIDE-CLI] compiler-log-end", rendered) - def test_resolve_exit_code_mapping(self) -> None: - self.assertEqual(eide_rebuild.resolve_exit_code({"ok": True}), 0) - self.assertEqual(eide_rebuild.resolve_exit_code({"ok": False, "errorCode": "BUILD_FAILED"}), 6) - self.assertEqual(eide_rebuild.resolve_exit_code({"ok": False, "errorCode": "LOG_MISSING"}), 8) +class BuilderParamsTests(unittest.TestCase): + def test_collect_sources_respects_virtual_subtree_exclude_and_binary_extensions(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (project_dir / "src").mkdir() + (project_dir / "src" / "main.c").write_text("int main(void) { return 0; }\n", encoding="utf-8") + (project_dir / "src" / "keep.a").write_text("archive\n", encoding="utf-8") + (project_dir / "src" / "keep.o").write_text("object\n", encoding="utf-8") + (project_dir / "src" / "generated").mkdir() + (project_dir / "src" / "generated" / "skip.c").write_text("int skip(void) { return 0; }\n", encoding="utf-8") + (project_dir / "libs").mkdir() + (project_dir / "libs" / "keep.lib").write_text("library\n", encoding="utf-8") + (project_dir / "objs").mkdir() + (project_dir / "objs" / "keep.obj").write_text("object\n", encoding="utf-8") + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: + name: + files: [] + folders: + - name: src + files: + - { path: src/main.c } + - { path: src/keep.a } + - { path: src/keep.o } + folders: + - name: generated + files: + - { path: src/generated/skip.c } + folders: [] + - name: libs + files: + - { path: libs/keep.lib } + folders: [] + - name: objs + files: + - { path: objs/keep.obj } + folders: [] +targets: + Debug: + toolchain: GCC + excludeList: + - /src/generated + cppPreprocessAttrs: { incList: [], libList: [], defineList: [USE_FULL_LL_DRIVER] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + floatingPointHardware: none + archExtensions: "" + scatterFilePath: linker.ld + options: + global: {} + linker: + output-format: elf +''', + encoding="utf-8", + ) + model = eide_rebuild.load_eide_model(eide_dir / "eide.yml") + debug_target = model.payload["targets"]["Debug"] -class MainFlowTests(unittest.TestCase): - def test_main_emits_failure_protocol_with_full_log(self) -> None: + source_list = builder_params_module.collect_sources( + model.payload["virtualFolder"], + debug_target["excludeList"], + ) + + self.assertEqual( + source_list, + [ + "libs/keep.lib", + "objs/keep.obj", + "src/keep.a", + "src/keep.o", + "src/main.c", + ], + ) + + def test_generate_builder_params_builds_full_shape_with_cpp_and_source_params(self) -> None: with make_temp_dir() as temp_dir: - workspace_file = Path(temp_dir) / "demo.code-workspace" - workspace_file.write_text("{}", encoding="utf-8") - log_file = Path(temp_dir) / "compiler.log" - log_file.write_text("compile failed", encoding="utf-8") + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (project_dir / "src").mkdir() + (project_dir / "src" / "main.cpp").write_text("int main() { return 0; }\n", encoding="utf-8") + (project_dir / "src" / "helper.c").write_text("int helper(void) { return 0; }\n", encoding="utf-8") + (project_dir / "src" / "skip").mkdir() + (project_dir / "src" / "skip" / "dead.c").write_text("int dead(void) { return 0; }\n", encoding="utf-8") + (project_dir / "libs").mkdir() + (project_dir / "libs" / "driver.a").write_text("archive\n", encoding="utf-8") + (project_dir / "objs").mkdir() + (project_dir / "objs" / "startup.obj").write_text("object\n", encoding="utf-8") + (project_dir / "linker.ld").write_text("MEMORY {}\n", encoding="utf-8") + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: + name: + files: [] + folders: + - name: src + files: + - { path: src/main.cpp } + - { path: src/helper.c } + folders: + - name: skip + files: + - { path: src/skip/dead.c } + folders: [] + - name: libs + files: + - { path: libs/driver.a } + folders: [] + - name: objs + files: + - { path: objs/startup.obj } + folders: [] +targets: + Debug: + toolchain: GCC + excludeList: + - /src/skip + cppPreprocessAttrs: + incList: [include, config] + libList: [libs] + defineList: [USE_FULL_LL_DRIVER, DEBUG] + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + floatingPointHardware: single + archExtensions: "+fp" + scatterFilePath: linker.ld + options: + global: + misc-control: keep + beforeBuildTasks: + - name: generate version + command: python Scripts/get_version.py + linker: + output-format: elf + afterBuildTasks: + - name: pack image + command: python Scripts/pack_image.py +''', + encoding="utf-8", + ) + (eide_dir / "files.options.yml").write_text( + ''' +options: + Debug: + files: + src/main.cpp: + custom: + optimize: O2 +''', + encoding="utf-8", + ) + + params = eide_rebuild.generate_builder_params(project_dir, "Debug", "C:/EIDE", "C:/gcc-arm") + + self.assertEqual( + set(params.keys()), + { + "alwaysInBuildSources", + "buildMode", + "defines", + "dumpPath", + "env", + "incDirs", + "libDirs", + "name", + "options", + "outDir", + "rootDir", + "showRepathOnLog", + "sourceList", + "sourceParams", + "sysPaths", + "target", + "threadNum", + "toolchain", + "toolchainCfgFile", + "toolchainLocation", + }, + ) + self.assertEqual(params["name"], "demo") + self.assertEqual(params["target"], "Debug") + self.assertEqual(params["toolchain"], "GCC") + self.assertEqual(params["toolchainLocation"], "C:/gcc-arm") + self.assertEqual(params["toolchainCfgFile"], "C:/EIDE/arm.gcc.model.json") + self.assertEqual(params["buildMode"], "fast|multhread") + self.assertTrue(params["showRepathOnLog"]) + self.assertEqual(params["threadNum"], os.cpu_count() or 4) + self.assertEqual(params["rootDir"], project_dir.as_posix()) + self.assertEqual(params["dumpPath"], "build/Debug") + self.assertEqual(params["outDir"], "build/Debug") + self.assertEqual(params["incDirs"], ["include", "config"]) + self.assertEqual(params["libDirs"], ["libs"]) + self.assertEqual(params["defines"], ["USE_FULL_LL_DRIVER", "DEBUG"]) + self.assertEqual(params["options"]["global"]["toolPrefix"], "arm-none-eabi-") + self.assertEqual(params["options"]["global"]["microcontroller-cpu"], "cortex-m33-sp") + self.assertEqual(params["options"]["global"]["microcontroller-fpu"], "cortex-m33-sp") + self.assertEqual(params["options"]["global"]["microcontroller-float"], "cortex-m33-sp") + self.assertEqual(params["options"]["global"]["$arch-extensions"], "+fp") + self.assertEqual(params["options"]["global"]["$clang-arch-extensions"], "") + self.assertEqual(params["options"]["global"]["$armlink-arch-extensions"], "") + self.assertEqual(params["options"]["linker"]["$toolName"], "g++") + self.assertEqual(params["options"]["linker"]["link-scatter"], [f"{project_dir.as_posix()}/linker.ld"]) + self.assertEqual(params["options"]["beforeBuildTasks"][0]["name"], "generate version") + self.assertEqual(params["options"]["afterBuildTasks"][0]["name"], "pack image") + self.assertEqual( + params["sourceList"], + [ + "libs/driver.a", + "objs/startup.obj", + "src/helper.c", + "src/main.cpp", + ], + ) + self.assertEqual( + params["sourceParams"], + { + "src/main.cpp": { + "custom": { + "optimize": "O2", + } + } + }, + ) + self.assertEqual(params["alwaysInBuildSources"], []) + self.assertEqual(params["sysPaths"], []) + self.assertEqual( + set(params["env"].keys()), + { + "ChipName", + "ChipPackDir", + "ConfigName", + "ExecutableName", + "OutDir", + "OutDirBase", + "OutDirRoot", + "ProjectName", + "ProjectRoot", + "SYS_DirSep", + "SYS_DirSeparator", + "SYS_EOL", + "SYS_PathSep", + "SYS_PathSeparator", + "SYS_Platform", + "ToolchainRoot", + "workspaceFolder", + "workspaceFolderBasename", + }, + ) + self.assertEqual(params["env"]["ProjectName"], "demo") + self.assertEqual(params["env"]["ConfigName"], "Debug") + self.assertEqual(params["env"]["ProjectRoot"], project_dir.as_posix()) + self.assertEqual(params["env"]["ToolchainRoot"], "C:/gcc-arm") + self.assertEqual(params["env"]["workspaceFolder"], project_dir.as_posix()) + self.assertEqual(params["env"]["workspaceFolderBasename"], project_dir.name) + self.assertEqual(params["env"]["OutDir"], f"{project_dir.as_posix()}/build/Debug") + self.assertEqual(params["env"]["OutDirRoot"], "build") + self.assertEqual(params["env"]["OutDirBase"], "build/Debug") + self.assertEqual(params["env"]["ExecutableName"], f"{project_dir.as_posix()}/build/Debug/demo") + self.assertEqual(params["env"]["SYS_Platform"], "windows" if os.name == "nt" else "linux") + self.assertEqual(params["env"]["SYS_DirSep"], "\\" if os.name == "nt" else "/") + self.assertEqual(params["env"]["SYS_DirSeparator"], "\\" if os.name == "nt" else "/") + self.assertEqual(params["env"]["SYS_PathSep"], ";" if os.name == "nt" else ":") + self.assertEqual(params["env"]["SYS_PathSeparator"], ";" if os.name == "nt" else ":") + self.assertEqual(params["env"]["SYS_EOL"], "\n") - response = { - "ok": False, - "workspacePath": str(workspace_file), + def test_write_builder_params_writes_expected_format(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + params = { + "name": "demo", "target": "Debug", - "logPath": str(log_file), - "durationMs": 1234, - "errorCode": "BUILD_FAILED", - "message": "rebuild failed", } + output_path = eide_rebuild.write_builder_params(project_dir, "Debug", params) + + self.assertEqual(output_path, project_dir / "build" / "Debug" / "builder.params") + content = output_path.read_text(encoding="utf-8") + self.assertTrue(content.endswith("\n}")) + self.assertIn('\n "name": "demo"', content) + self.assertEqual(json.loads(content), params) + + def test_pre_handle_options_uses_linker_lib_for_library_output(self) -> None: + options = { + "global": {}, + "linker": { + "output-format": "lib", + }, + } + + builder_params_module._pre_handle_options( + options, + ["src/main.c"], + "Cortex-M33", + "none", + "", + "linker.ld", + Path("D:/demo"), + ) + + self.assertEqual(options["linker"]["$toolName"], "gcc") + self.assertEqual(options["linker"]["$use"], "linker-lib") + + def test_source_exts_match_plan_contract(self) -> None: + self.assertEqual( + builder_params_module.SOURCE_EXTS, + {".c", ".cpp", ".cc", ".cxx", ".s", ".a", ".o", ".lib", ".obj"}, + ) + + def test_generate_builder_params_raises_key_error_for_unknown_target(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: + toolchain: GCC + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: {global: {}, linker: {output-format: elf}} +''', + encoding="utf-8", + ) + + with self.assertRaises(KeyError) as error: + eide_rebuild.generate_builder_params(project_dir, "Release", "C:/EIDE", "C:/gcc-arm") + + self.assertEqual(error.exception.args[0], "Release") + + def test_generate_builder_params_normalizes_mixed_slash_toolchain_cfg_file(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (project_dir / "linker.ld").write_text("MEMORY {}\n", encoding="utf-8") + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + floatingPointHardware: none + archExtensions: "" + scatterFilePath: linker.ld + options: + global: {} + linker: + output-format: elf +''', + encoding="utf-8", + ) + + params = eide_rebuild.generate_builder_params(project_dir, "Debug", r"C:\EIDE/", "C:/gcc-arm") + + self.assertEqual(params["toolchainCfgFile"], "C:/EIDE/arm.gcc.model.json") + + +class JsonProtocolTests(unittest.TestCase): + def test_main_emits_single_json_object(self) -> None: + result = eide_rebuild.RunResult( + schema_version="1", + ok=True, + exit_code=0, + error_code="OK", + message="", + mode="rebuild-all", + platform="windows", + workspace_path="C:/work/demo.code-workspace", + project_root="C:/work", + project_name="demo", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:06Z", + duration_ms=2000, + summary={"discovered": 1, "passed": 1, "failed": 0}, + target_names=["Debug"], + transcript="full transcript", + targets=[], + ) + + payload = eide_rebuild.render_json_result(result) + parsed = json.loads(payload) + + self.assertEqual(parsed["errorCode"], "OK") + self.assertEqual(parsed["summary"]["passed"], 1) + self.assertIn("targets", parsed) + + def test_result_contract_includes_target_step_and_artifact_details(self) -> None: + step = eide_rebuild.StepResult( + kind="unify-builder", + name="build Debug", + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1000, + stdout="[ INFO ] start building\n", + stderr="", + command=["dotnet", "unify_builder.dll"], + cwd="C:/work/demo", + ) + target = eide_rebuild.TargetResult( + name="Debug", + index=1, + total=1, + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:06Z", + duration_ms=2000, + builder_params_path="build/Debug/builder.params", + builder_params_summary={"sourceCount": 103}, + compiler_log_path="build/Debug/compiler.log", + compiler_log="[ DONE ] build successfully !\n", + stack_report_json_path="build/Debug/stack_report.json", + stack_report_html_path="build/Debug/stack_report.html", + source_stats={"totalFiles": 103, "jobs": 8}, + memory=[{"name": "FLASH", "used": 139076, "total": 184320, "percent": 75.45, "unit": "B"}], + artifacts=[{"path": "build/Debug/app.bin", "kind": "bin", "size": 139104}], + transcript="target transcript", + steps=[step], + ) + + result = eide_rebuild.build_run_result( + workspace_path="C:/work/demo.code-workspace", + project_root=Path("C:/work/demo"), + project_name="demo", + platform_name="windows", + target_names=["Debug"], + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:06Z", + duration_ms=2000, + targets=[target], + transcript="full transcript", + ) + + payload = json.loads(eide_rebuild.render_json_result(result)) + + self.assertEqual(payload["schemaVersion"], "1") + self.assertTrue(payload["ok"]) + self.assertEqual(payload["summary"], {"discovered": 1, "passed": 1, "failed": 0}) + self.assertEqual(payload["targets"][0]["builderParamsSummary"]["sourceCount"], 103) + self.assertEqual(payload["targets"][0]["memory"][0]["name"], "FLASH") + self.assertEqual(payload["targets"][0]["artifacts"][0]["kind"], "bin") + self.assertEqual(payload["targets"][0]["steps"][0]["kind"], "unify-builder") + self.assertEqual(payload["targets"][0]["steps"][0]["command"], ["dotnet", "unify_builder.dll"]) + + def test_write_run_result_writes_same_json_payload(self) -> None: + with make_temp_dir() as temp_dir: + result = eide_rebuild.build_run_result( + workspace_path="", + project_root=Path(temp_dir), + project_name="demo", + platform_name="windows", + target_names=[], + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:04Z", + duration_ms=0, + targets=[], + transcript="", + ) + output_path = Path(temp_dir) / "build" / "rebuild_result.json" + + eide_rebuild.write_run_result(output_path, result) + + self.assertEqual(json.loads(output_path.read_text(encoding="utf-8")), json.loads(eide_rebuild.render_json_result(result))) + + +class ToolDiscoveryTests(unittest.TestCase): + def test_prefers_explicit_unify_builder_override(self) -> None: + with make_temp_dir() as temp_dir: + tool_path = Path(temp_dir) / "unify_builder.dll" + tool_path.write_text("tool", encoding="utf-8") + + with mock.patch.dict(os.environ, {"EIDE_REBUILD_UNIFY_BUILDER": str(tool_path)}, clear=False): + result = eide_rebuild.find_unify_builder() + + self.assertEqual(result, tool_path.resolve().as_posix()) + + def test_resolves_unify_builder_from_eide_tools_dir(self) -> None: + with make_temp_dir() as temp_dir: + tool_path = Path(temp_dir) / "unify_builder.dll" + tool_path.write_text("tool", encoding="utf-8") + + with mock.patch.dict(os.environ, {"EIDE_REBUILD_EIDE_TOOLS_DIR": temp_dir}, clear=False): + result = eide_rebuild.find_unify_builder() + + self.assertEqual(result, tool_path.resolve().as_posix()) + + def test_discovers_eide_layout_from_vscode_extension_root(self) -> None: + with make_temp_dir() as temp_dir: + extensions_root = Path(temp_dir) / "extensions" + extension_root = extensions_root / "cl.eide-3.26.7" + models_dir = extension_root / "res" / "data" / "models" + unify_builder = extension_root / "res" / "tools" / "win32" / "unify_builder" / "unify_builder.dll" + utils_dir = extension_root / "res" / "tools" / "win32" / "utils" + models_dir.mkdir(parents=True) + unify_builder.parent.mkdir(parents=True) + utils_dir.mkdir(parents=True) + (models_dir / "arm.gcc.model.json").write_text("{}", encoding="utf-8") + unify_builder.write_text("tool", encoding="utf-8") + (utils_dir / "python3.cmd").write_text("@echo off\r\npython.exe %*\r\n", encoding="utf-8") + + with mock.patch.dict( + os.environ, + { + "EIDE_REBUILD_VSCODE_EXTENSIONS_ROOT": str(extensions_root), + }, + clear=False, + ): + self.assertEqual(eide_rebuild.find_eide_tools_dir(), models_dir.resolve().as_posix()) + self.assertEqual(eide_rebuild.find_unify_builder(), unify_builder.resolve().as_posix()) + self.assertEqual(eide_rebuild.find_eide_utils_dir(), utils_dir.resolve().as_posix()) + + def test_prefers_unify_builder_exe_on_windows_layout(self) -> None: + with make_temp_dir() as temp_dir: + extensions_root = Path(temp_dir) / "extensions" + extension_root = extensions_root / "cl.eide-3.26.7" + unify_dir = extension_root / "res" / "tools" / "win32" / "unify_builder" + models_dir = extension_root / "res" / "data" / "models" + unify_dir.mkdir(parents=True) + models_dir.mkdir(parents=True) + (models_dir / "arm.gcc.model.json").write_text("{}", encoding="utf-8") + (unify_dir / "unify_builder.exe").write_text("exe", encoding="utf-8") + (unify_dir / "unify_builder.dll").write_text("dll", encoding="utf-8") + + with mock.patch.dict(os.environ, {"EIDE_REBUILD_VSCODE_EXTENSIONS_ROOT": str(extensions_root)}, clear=False): + self.assertEqual( + eide_rebuild.find_unify_builder(), + (unify_dir / "unify_builder.exe").resolve().as_posix(), + ) + + def test_discovers_latest_toolchain_root_from_eide_tools_home(self) -> None: + with make_temp_dir() as temp_dir: + tools_root = Path(temp_dir) / "tools" + old_root = tools_root / "xpack-arm-none-eabi-gcc-14.2.1-1.1" / "bin" + new_root = tools_root / "xpack-arm-none-eabi-gcc-15.2.1-1.1" / "bin" + old_root.mkdir(parents=True) + new_root.mkdir(parents=True) + (old_root / "arm-none-eabi-gcc.exe").write_text("gcc", encoding="utf-8") + (new_root / "arm-none-eabi-gcc.exe").write_text("gcc", encoding="utf-8") + + with mock.patch.dict( + os.environ, + { + "EIDE_REBUILD_TOOLS_ROOT": str(tools_root), + }, + clear=False, + ): + self.assertEqual(eide_rebuild.find_toolchain_root(), new_root.parent.resolve().as_posix()) + + def test_build_process_env_prepends_utils_and_toolchain_bin(self) -> None: + with make_temp_dir() as temp_dir: + toolchain_root = Path(temp_dir) / "xpack-arm-none-eabi-gcc-15.2.1-1.1" + toolchain_bin = toolchain_root / "bin" + utils_dir = Path(temp_dir) / "utils" + toolchain_bin.mkdir(parents=True) + utils_dir.mkdir(parents=True) + + with mock.patch("eide_rebuild.tools.find_eide_utils_dir", return_value=utils_dir.resolve().as_posix()): + env = eide_rebuild.build_process_env({"ProjectName": "demo"}, toolchain_root.resolve().as_posix()) + + path_parts = env["PATH"].split(os.pathsep) + self.assertEqual(path_parts[0], str(utils_dir.resolve())) + self.assertEqual(path_parts[1], str(toolchain_bin.resolve())) + self.assertEqual(env["ProjectName"], "demo") + + +class DoctorTests(unittest.TestCase): + def test_doctor_reports_discovered_tools(self) -> None: + with ( + mock.patch("eide_rebuild.tools.find_dotnet", return_value="C:/dotnet/dotnet.exe"), + mock.patch("eide_rebuild.tools.find_eide_extension_dir", return_value="C:/EIDE/extension"), + mock.patch("eide_rebuild.tools.find_eide_tools_dir", return_value="C:/EIDE/models"), + mock.patch("eide_rebuild.tools.find_unify_builder", return_value="C:/EIDE/unify_builder.exe"), + mock.patch("eide_rebuild.tools.find_toolchain_root", return_value="C:/gcc-arm"), + mock.patch("eide_rebuild.tools.find_eide_utils_dir", return_value="C:/EIDE/utils"), + mock.patch( + "eide_rebuild.tools.check_unify_builder_runtime", + return_value={"ok": True, "requiredFramework": "Microsoft.NETCore.App", "requiredVersion": "6.0.0"}, + ), + ): + result = eide_rebuild.run_doctor() + + self.assertTrue(result["ok"]) + self.assertEqual(result["tools"]["dotnet"], "C:/dotnet/dotnet.exe") + self.assertEqual(result["tools"]["eideUtilsDir"], "C:/EIDE/utils") + self.assertEqual(result["runtime"]["requiredVersion"], "6.0.0") + + def test_doctor_reports_missing_unify_builder_runtime(self) -> None: + with ( + mock.patch("eide_rebuild.tools.find_dotnet", return_value="C:/dotnet/dotnet.exe"), + mock.patch("eide_rebuild.tools.find_eide_extension_dir", return_value="C:/EIDE/extension"), + mock.patch("eide_rebuild.tools.find_eide_tools_dir", return_value="C:/EIDE/models"), + mock.patch("eide_rebuild.tools.find_unify_builder", return_value="C:/EIDE/unify_builder.exe"), + mock.patch("eide_rebuild.tools.find_toolchain_root", return_value="C:/gcc-arm"), + mock.patch("eide_rebuild.tools.find_eide_utils_dir", return_value="C:/EIDE/utils"), + mock.patch( + "eide_rebuild.tools.check_unify_builder_runtime", + return_value={ + "ok": False, + "requiredFramework": "Microsoft.NETCore.App", + "requiredVersion": "6.0.0", + "message": "Missing Microsoft.NETCore.App 6.0 runtime.", + }, + ), + ): + result = eide_rebuild.run_doctor() + + self.assertFalse(result["ok"]) + self.assertEqual(result["exitCode"], 3) + self.assertEqual(result["errorCode"], "TOOL_NOT_FOUND") + self.assertIn("6.0", result["message"]) + + def test_main_supports_doctor_command(self) -> None: + stdout_buffer = io.StringIO() + with ( + mock.patch.object( + eide_rebuild, + "run_doctor", + return_value={ + "ok": True, + "exitCode": 0, + "errorCode": "OK", + "message": "", + "platform": "windows", + "tools": {}, + }, + ), + redirect_stdout(stdout_buffer), + ): + exit_code = eide_rebuild.main(["doctor"]) + + self.assertEqual(exit_code, 0) + self.assertTrue(json.loads(stdout_buffer.getvalue())["ok"]) + + +class ExecutorTests(unittest.TestCase): + def test_records_step_stdout_stderr_command_cwd_and_transcript(self) -> None: + completed = subprocess.CompletedProcess( + args=["dotnet", "unify_builder.dll"], + returncode=0, + stdout="[ INFO ] start building\n[DONE] build successfully !\n", + stderr="", + ) + + with mock.patch("eide_rebuild.executor.subprocess.run", return_value=completed) as run_mock: + step = eide_rebuild.run_step( + kind="unify-builder", + name="build Debug", + command=["dotnet", "unify_builder.dll"], + cwd=Path.cwd(), + ) + + self.assertTrue(step.ok) + self.assertEqual(step.exit_code, 0) + self.assertEqual(step.error_code, "OK") + self.assertIn("start building", step.stdout) + self.assertEqual(step.stderr, "") + self.assertEqual(step.command, ["dotnet", "unify_builder.dll"]) + self.assertEqual(step.cwd, Path.cwd().resolve().as_posix()) + run_mock.assert_called_once() + + def test_build_unify_builder_command_uses_dotnet_exec_with_roll_forward(self) -> None: + with make_temp_dir() as temp_dir: + unify_root = Path(temp_dir) + exe_path = unify_root / "unify_builder.exe" + dll_path = unify_root / "unify_builder.dll" + exe_path.write_text("exe", encoding="utf-8") + dll_path.write_text("dll", encoding="utf-8") + + command = eide_rebuild.build_unify_builder_command( + dotnet_path="C:/Program Files/dotnet/dotnet.exe", + unify_builder_path=exe_path.as_posix(), + builder_params_path="D:/repo/build/Debug/builder.params", + ) + + self.assertEqual( + command, + [ + "C:/Program Files/dotnet/dotnet.exe", + "exec", + "--roll-forward", + "Major", + dll_path.resolve().as_posix(), + "-p", + "D:/repo/build/Debug/builder.params", + ], + ) + + +class TargetExecutionTests(unittest.TestCase): + def test_rebuild_target_collects_memory_artifacts_and_stack_reports(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + build_dir = project_dir / "build" / "Debug" + build_dir.mkdir(parents=True) + (project_dir / "linker.ld").write_text("MEMORY {}\n", encoding="utf-8") + (build_dir / "compiler.log").write_text("[ DONE ] build successfully !\n", encoding="utf-8") + (build_dir / "app.bin").write_bytes(b"abc") + (build_dir / "stack_report.json").write_text('{"pct_guard": 67.19}', encoding="utf-8") + (build_dir / "stack_report.html").write_text("", encoding="utf-8") + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: + name: + files: + - { path: main.c } + folders: [] +targets: + Debug: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: + global: {} + linker: { output-format: elf } +''', + encoding="utf-8", + ) + step = eide_rebuild.StepResult( + kind="unify-builder", + name="build Debug", + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1000, + stdout=( + "[ INFO ] file statistics (incremental mode)\n" + "+---------+-----------+-----------+---------------+--------+\n" + "| C Files | Cpp Files | Asm Files | Lib/Obj Files | Totals |\n" + "+---------+-----------+-----------+---------------+--------+\n" + "| 99 | 0 | 1 | 3 | 103 |\n" + "+---------+-----------+-----------+---------------+--------+\n" + "[ INFO ] start compiling (jobs: 8) ...\n" + "Memory region Used Size Region Size %age Used\n" + " RAM: 37816 B 64 KB 57.70%\n" + " FLASH: 139076 B 180 KB 75.45%\n" + ), + stderr="", + command=["dotnet", "unify_builder.dll", "-p", "build/Debug/builder.params"], + cwd=project_dir.as_posix(), + ) + + with mock.patch("eide_rebuild.executor.run_step", return_value=step): + target = eide_rebuild.rebuild_target( + project_root=project_dir, + project_name="demo", + target_name="Debug", + target_index=1, + target_total=1, + dotnet_path="C:/dotnet/dotnet.exe", + unify_builder_path="C:/EIDE/unify_builder.dll", + eide_tools_dir="C:/EIDE", + toolchain_root="C:/gcc-arm", + ) + + self.assertTrue(target.ok) + self.assertEqual(target.error_code, "OK") + self.assertEqual(target.compiler_log, "[ DONE ] build successfully !\n") + self.assertTrue(target.stack_report_json_path.endswith("/build/Debug/stack_report.json")) + self.assertTrue(target.stack_report_html_path.endswith("/build/Debug/stack_report.html")) + self.assertEqual(target.artifacts[0]["kind"], "bin") + self.assertEqual(target.source_stats["cFiles"], 99) + self.assertEqual(target.source_stats["jobs"], 8) + self.assertEqual(target.memory[0]["name"], "RAM") + self.assertEqual(target.memory[1]["name"], "FLASH") + self.assertEqual(target.steps[0].kind, "generate-builder-params") + self.assertEqual(target.steps[1].kind, "unify-builder") + + def test_rebuild_target_marks_missing_compiler_log(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (project_dir / "linker.ld").write_text("MEMORY {}\n", encoding="utf-8") + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: + global: {} + linker: { output-format: elf } +''', + encoding="utf-8", + ) + step = eide_rebuild.StepResult( + kind="unify-builder", + name="build Debug", + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1000, + stdout="", + stderr="", + command=["dotnet", "unify_builder.dll", "-p", "build/Debug/builder.params"], + cwd=project_dir.as_posix(), + ) + + with mock.patch("eide_rebuild.executor.run_step", return_value=step): + target = eide_rebuild.rebuild_target( + project_root=project_dir, + project_name="demo", + target_name="Debug", + target_index=1, + target_total=1, + dotnet_path="C:/dotnet/dotnet.exe", + unify_builder_path="C:/EIDE/unify_builder.dll", + eide_tools_dir="C:/EIDE", + toolchain_root="C:/gcc-arm", + ) + + self.assertFalse(target.ok) + self.assertEqual(target.exit_code, 8) + self.assertEqual(target.error_code, "COMPILER_LOG_MISSING") + + +class TargetHookTests(unittest.TestCase): + def test_rebuild_target_runs_unify_builder_once_for_hooked_project(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + build_dir = project_dir / "build" / "Debug" + build_dir.mkdir(parents=True) + builder_params_path = build_dir / "builder.params" + builder_params_path.write_text("{}", encoding="utf-8") + (build_dir / "compiler.log").write_text("[ DONE ] build successfully !\n", encoding="utf-8") + params = { + "toolchain": "GCC", + "threadNum": 8, + "sourceList": ["main.c"], + "options": { + "beforeBuildTasks": [ + {"name": "generate version", "command": "python ${ProjectName}.py"}, + {"name": "disabled hook", "command": "echo skip", "disable": True}, + ], + "afterBuildTasks": [ + {"name": "pack image", "command": "echo ${OutDirBase}"}, + ], + }, + "env": { + "ProjectName": "demo", + "ConfigName": "Debug", + "OutDirBase": "build/Debug", + "workspaceFolder": project_dir.as_posix(), + }, + } + calls: list[tuple[str, str, object]] = [] + + def fake_run_step(kind: str, name: str, command, cwd: Path, env=None): + calls.append((kind, name, command)) + return eide_rebuild.StepResult( + kind=kind, + name=name, + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1, + stdout="ok\n", + stderr="", + command=[command] if isinstance(command, str) else list(command), + cwd=project_dir.as_posix(), + ) + + with ( + mock.patch("eide_rebuild.executor.generate_builder_params", return_value=params), + mock.patch("eide_rebuild.executor.write_builder_params", return_value=builder_params_path), + mock.patch("eide_rebuild.executor.run_step", side_effect=fake_run_step), + ): + target = eide_rebuild.rebuild_target( + project_root=project_dir, + project_name="demo", + target_name="Debug", + target_index=1, + target_total=1, + dotnet_path="C:/dotnet/dotnet.exe", + unify_builder_path="C:/EIDE/unify_builder.dll", + eide_tools_dir="C:/EIDE", + toolchain_root="C:/gcc-arm", + ) + + self.assertTrue(target.ok) + self.assertEqual([step.kind for step in target.steps], ["generate-builder-params", "unify-builder"]) + self.assertEqual(len(calls), 1) + self.assertEqual(calls[0][0], "unify-builder") + + def test_rebuild_target_marks_embedded_post_build_failure(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + build_dir = project_dir / "build" / "Debug" + build_dir.mkdir(parents=True) + builder_params_path = build_dir / "builder.params" + builder_params_path.write_text("{}", encoding="utf-8") + (build_dir / "compiler.log").write_text("[ DONE ] build successfully !\n", encoding="utf-8") + params = { + "toolchain": "GCC", + "threadNum": 8, + "sourceList": ["main.c"], + "options": { + "beforeBuildTasks": [], + "afterBuildTasks": [{"name": "pack image", "command": "python pack.py"}], + }, + "env": {"ProjectName": "demo", "ConfigName": "Debug", "OutDirBase": "build/Debug"}, + } + + def fake_run_step(kind: str, name: str, command, cwd: Path, env=None): + return eide_rebuild.StepResult( + kind="unify-builder", + name="build Debug", + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1, + stdout=( + "[ INFO ] pre-build tasks ...\n\n" + ">> generate version\t\t[done]\n\n" + "[ INFO ] post-build tasks ...\n\n" + ">> pack image\t\t[failed]\n\n" + "ERROR: pack failed\n" + ), + stderr="", + command=[command] if isinstance(command, str) else list(command), + cwd=project_dir.as_posix(), + ) + + with ( + mock.patch("eide_rebuild.executor.generate_builder_params", return_value=params), + mock.patch("eide_rebuild.executor.write_builder_params", return_value=builder_params_path), + mock.patch("eide_rebuild.executor.run_step", side_effect=fake_run_step), + ): + target = eide_rebuild.rebuild_target( + project_root=project_dir, + project_name="demo", + target_name="Debug", + target_index=1, + target_total=1, + dotnet_path="C:/dotnet/dotnet.exe", + unify_builder_path="C:/EIDE/unify_builder.dll", + eide_tools_dir="C:/EIDE", + toolchain_root="C:/gcc-arm", + ) + + self.assertFalse(target.ok) + self.assertEqual(target.exit_code, 4) + self.assertEqual(target.error_code, "POST_BUILD_TASK_FAILED") + self.assertEqual(target.message, "pack image failed inside unify_builder.") + self.assertEqual([step.kind for step in target.steps], ["generate-builder-params", "unify-builder"]) + + +class PackageExportsTests(unittest.TestCase): + def test_package_exports_direct_builder_api(self) -> None: + self.assertTrue(callable(eide_rebuild.main)) + self.assertTrue(callable(eide_rebuild.rebuild_target)) + self.assertTrue(callable(eide_rebuild.render_json_result)) + self.assertIs(eide_rebuild.ExitError, eide_rebuild.__dict__["ExitError"]) + + def test_main_uses_package_rebuild_target_patch(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: { global: {}, linker: {} } +''', + encoding="utf-8", + ) + target_result = eide_rebuild.TargetResult( + name="Debug", + index=1, + total=1, + ok=True, + exit_code=0, + error_code="OK", + message="", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1000, + builder_params_path=f"{project_dir.as_posix()}/build/Debug/builder.params", + compiler_log_path=f"{project_dir.as_posix()}/build/Debug/compiler.log", + compiler_log="[ DONE ] build successfully !\n", + transcript="[1/1] Building: Debug\n", + ) stdout_buffer = io.StringIO() - stderr_buffer = io.StringIO() with ( - mock.patch.object(eide_rebuild, "get_code_command_path", return_value=Path(r"C:\Tools\code.cmd")), - mock.patch.object(eide_rebuild, "discover_vsix_path", return_value=Path(r"C:\bundle\eide-rebuild.cli-bridge-0.1.0.vsix")), - mock.patch.object(eide_rebuild, "ensure_bridge_installed", return_value=False), - mock.patch.object(eide_rebuild, "read_registration", return_value={"pipeName": "demo"}), - mock.patch.object(eide_rebuild, "test_registration_alive", return_value=True), - mock.patch.object(eide_rebuild, "invoke_bridge_request", return_value=response), + mock.patch.object(eide_rebuild, "find_dotnet", return_value="C:/dotnet/dotnet.exe"), + mock.patch.object(eide_rebuild, "find_unify_builder", return_value="C:/EIDE/unify_builder.dll"), + mock.patch.object(eide_rebuild, "find_eide_tools_dir", return_value="C:/EIDE"), + mock.patch.object(eide_rebuild, "find_toolchain_root", return_value="C:/gcc-arm"), + mock.patch.object( + eide_rebuild, + "check_unify_builder_runtime", + return_value={"ok": True, "requiredFramework": "Microsoft.NETCore.App", "requiredVersion": "6.0.0"}, + ), + mock.patch.object(eide_rebuild, "rebuild_target", return_value=target_result) as rebuild_target, redirect_stdout(stdout_buffer), - redirect_stderr(stderr_buffer), ): - exit_code = eide_rebuild.main(["rebuild", str(workspace_file)]) + exit_code = eide_rebuild.main(["rebuild", str(project_dir)]) - self.assertEqual(exit_code, 6) - self.assertIn("[EIDE-CLI] result=failure", stdout_buffer.getvalue()) - self.assertIn("compile failed", stdout_buffer.getvalue()) - self.assertIn("rebuild failed", stderr_buffer.getvalue()) + self.assertEqual(exit_code, 0) + rebuild_target.assert_called_once() + self.assertEqual(json.loads(stdout_buffer.getvalue())["summary"]["passed"], 1) - def test_wait_for_registration_removes_stale_entry(self) -> None: - workspace_file = Path(r"C:\work\demo\demo.code-workspace") - stale = {"pipeName": "stale"} - live = {"pipeName": "live"} - with ( - mock.patch.object(eide_rebuild, "read_registration", side_effect=[stale, live]), - mock.patch.object(eide_rebuild, "test_registration_alive", side_effect=[False, True]), - mock.patch.object(eide_rebuild, "remove_registration") as remove_registration, - mock.patch.object(eide_rebuild.time, "sleep", return_value=None), - ): - result = eide_rebuild.wait_for_registration(workspace_file, 1000) +class DirectBuilderFlowTests(unittest.TestCase): + def test_main_rebuilds_all_targets_and_writes_json_file(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text( + ''' +name: demo +virtualFolder: {name: , files: [], folders: []} +targets: + Debug: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: { global: {}, linker: {} } + Release: + toolchain: GCC + cppPreprocessAttrs: { incList: [], libList: [], defineList: [] } + toolchainConfigMap: + GCC: + cpuType: Cortex-M33 + scatterFilePath: linker.ld + options: { global: {}, linker: {} } +''', + encoding="utf-8", + ) + + def make_target(name: str, index: int) -> eide_rebuild.TargetResult: + return eide_rebuild.TargetResult( + name=name, + index=index, + total=2, + ok=True, + exit_code=0, + error_code="OK", + message="", + builder_params_path=f"{project_dir.as_posix()}/build/{name}/builder.params", + compiler_log_path=f"{project_dir.as_posix()}/build/{name}/compiler.log", + compiler_log="[ DONE ] build successfully !\n", + started_at="2026-04-16T08:13:04Z", + finished_at="2026-04-16T08:13:05Z", + duration_ms=1000, + transcript=f"[{index}/2] Building: {name}\n", + source_stats={"jobs": 8, "totalFiles": 103}, + memory=[], + artifacts=[], + steps=[], + ) + + stdout_buffer = io.StringIO() + + with ( + mock.patch.object(eide_rebuild, "find_dotnet", return_value="C:/dotnet/dotnet.exe"), + mock.patch.object(eide_rebuild, "find_unify_builder", return_value="C:/EIDE/unify_builder.dll"), + mock.patch.object(eide_rebuild, "find_eide_tools_dir", return_value="C:/EIDE"), + mock.patch.object(eide_rebuild, "find_toolchain_root", return_value="C:/gcc-arm"), + mock.patch.object( + eide_rebuild, + "check_unify_builder_runtime", + return_value={"ok": True, "requiredFramework": "Microsoft.NETCore.App", "requiredVersion": "6.0.0"}, + ), + mock.patch.object(eide_rebuild, "rebuild_target", side_effect=[make_target("Debug", 1), make_target("Release", 2)]), + redirect_stdout(stdout_buffer), + ): + exit_code = eide_rebuild.main(["rebuild", str(project_dir)]) + + payload = json.loads(stdout_buffer.getvalue()) + + self.assertEqual(exit_code, 0) + self.assertEqual(payload["summary"]["discovered"], 2) + self.assertEqual(payload["summary"]["passed"], 2) + self.assertEqual(payload["targets"][1]["name"], "Release") + self.assertTrue((project_dir / "build" / "rebuild_result.json").exists()) + +class MainFlowTests(unittest.TestCase): + def test_main_emits_error_json_when_tool_is_missing(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") + stdout_buffer = io.StringIO() + + with ( + mock.patch.object(eide_rebuild, "find_dotnet", side_effect=FileNotFoundError("dotnet")), + redirect_stdout(stdout_buffer), + ): + exit_code = eide_rebuild.main(["rebuild", str(project_dir)]) + + payload = json.loads(stdout_buffer.getvalue()) + self.assertEqual(exit_code, 3) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["errorCode"], "TOOL_NOT_FOUND") + + def test_main_emits_error_json_for_multiple_workspace_files(self) -> None: + with make_temp_dir() as temp_dir: + project_dir = Path(temp_dir) + eide_dir = project_dir / ".eide" + eide_dir.mkdir() + (eide_dir / "eide.yml").write_text("name: demo\ntargets: {Debug: {}}\n", encoding="utf-8") + (project_dir / "a.code-workspace").write_text("{}", encoding="utf-8") + (project_dir / "b.code-workspace").write_text("{}", encoding="utf-8") + stdout_buffer = io.StringIO() + + with redirect_stdout(stdout_buffer): + exit_code = eide_rebuild.main(["rebuild", str(project_dir)]) - self.assertEqual(result, live) - remove_registration.assert_called_once_with(workspace_file) + payload = json.loads(stdout_buffer.getvalue()) + self.assertEqual(exit_code, 2) + self.assertFalse(payload["ok"]) + self.assertEqual(payload["errorCode"], "MULTIPLE_WORKSPACES") diff --git a/runtime/tests/test_skill_bundle_sync.py b/runtime/tests/test_skill_bundle_sync.py index a2465c1..1e2bb26 100644 --- a/runtime/tests/test_skill_bundle_sync.py +++ b/runtime/tests/test_skill_bundle_sync.py @@ -7,8 +7,16 @@ REPO_ROOT = Path(__file__).resolve().parents[2] RUNNER_SOURCE = REPO_ROOT / "runtime" / "python" / "eide_rebuild.py" RUNNER_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "scripts" / "eide_rebuild.py" -VSIX_SOURCE = REPO_ROOT / "runtime" / "bridge" / "dist" / "eide-rebuild.cli-bridge-0.1.0.vsix" -VSIX_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "assets" / "eide-rebuild.cli-bridge-0.1.0.vsix" +PACKAGE_SOURCE = REPO_ROOT / "runtime" / "python" / "eide_rebuild" +PACKAGE_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "scripts" / "eide_rebuild" + + +def _collect_files(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)).replace("\\", "/"): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } class SkillBundleSyncTests(unittest.TestCase): @@ -16,6 +24,6 @@ def test_runner_copy_matches_runtime(self) -> None: self.assertTrue(RUNNER_TARGET.exists()) self.assertEqual(RUNNER_SOURCE.read_bytes(), RUNNER_TARGET.read_bytes()) - def test_vsix_copy_matches_runtime(self) -> None: - self.assertTrue(VSIX_TARGET.exists()) - self.assertEqual(VSIX_SOURCE.read_bytes(), VSIX_TARGET.read_bytes()) + def test_runner_support_package_matches_runtime(self) -> None: + self.assertTrue(PACKAGE_TARGET.exists()) + self.assertEqual(_collect_files(PACKAGE_SOURCE), _collect_files(PACKAGE_TARGET)) diff --git a/scripts/sync_skill_runtime.py b/scripts/sync_skill_runtime.py index 13bace2..e2b4979 100644 --- a/scripts/sync_skill_runtime.py +++ b/scripts/sync_skill_runtime.py @@ -12,8 +12,9 @@ REPO_ROOT = Path(__file__).resolve().parent.parent RUNNER_SOURCE = REPO_ROOT / "runtime" / "python" / "eide_rebuild.py" RUNNER_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "scripts" / "eide_rebuild.py" -VSIX_SOURCE = REPO_ROOT / "runtime" / "bridge" / "dist" / "eide-rebuild.cli-bridge-0.1.0.vsix" -VSIX_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "assets" / "eide-rebuild.cli-bridge-0.1.0.vsix" +PACKAGE_SOURCE = REPO_ROOT / "runtime" / "python" / "eide_rebuild" +PACKAGE_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "scripts" / "eide_rebuild" +LEGACY_VSIX_TARGET = REPO_ROOT / "skills" / "eide-rebuild" / "assets" / "eide-rebuild.cli-bridge-0.1.0.vsix" # --- Helpers --- @@ -24,11 +25,27 @@ def files_match(left_path: Path, right_path: Path) -> bool: return left_path.read_bytes() == right_path.read_bytes() +def trees_match(left_root: Path, right_root: Path) -> bool: + if not left_root.exists() or not right_root.exists(): + return False + + def collect(root: Path) -> dict[str, bytes]: + return { + str(path.relative_to(root)).replace("\\", "/"): path.read_bytes() + for path in sorted(root.rglob("*")) + if path.is_file() + } + + return collect(left_root) == collect(right_root) + + def sync_copy() -> int: RUNNER_TARGET.parent.mkdir(parents=True, exist_ok=True) - VSIX_TARGET.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(RUNNER_SOURCE, RUNNER_TARGET) - shutil.copy2(VSIX_SOURCE, VSIX_TARGET) + if PACKAGE_TARGET.exists(): + shutil.rmtree(PACKAGE_TARGET) + shutil.copytree(PACKAGE_SOURCE, PACKAGE_TARGET) + LEGACY_VSIX_TARGET.unlink(missing_ok=True) print("Synchronized runtime artifacts into the skill bundle.") return 0 @@ -37,8 +54,8 @@ def sync_check() -> int: mismatches = [] if not files_match(RUNNER_SOURCE, RUNNER_TARGET): mismatches.append(f"Runner mismatch: {RUNNER_SOURCE} -> {RUNNER_TARGET}") - if not files_match(VSIX_SOURCE, VSIX_TARGET): - mismatches.append(f"VSIX mismatch: {VSIX_SOURCE} -> {VSIX_TARGET}") + if not trees_match(PACKAGE_SOURCE, PACKAGE_TARGET): + mismatches.append(f"Runner package mismatch: {PACKAGE_SOURCE} -> {PACKAGE_TARGET}") if mismatches: for message in mismatches: @@ -54,7 +71,7 @@ def sync_check() -> int: def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description="Synchronize the shared runtime into the skill bundle.") mode = parser.add_mutually_exclusive_group(required=True) - mode.add_argument("--copy", action="store_true", help="Copy the runtime runner and VSIX into the skill bundle.") + mode.add_argument("--copy", action="store_true", help="Copy the runtime runner and support package into the skill bundle.") mode.add_argument("--check", action="store_true", help="Verify that the skill bundle is synchronized.") arguments = parser.parse_args(argv) diff --git a/skills/eide-rebuild/SKILL.md b/skills/eide-rebuild/SKILL.md index 9d0b534..ebedc0a 100644 --- a/skills/eide-rebuild/SKILL.md +++ b/skills/eide-rebuild/SKILL.md @@ -1,6 +1,6 @@ --- name: eide-rebuild -description: Rebuilds an Embedded IDE for VS Code workspace through a Python runner and returns the full compiler.log as plain text. Use when the user asks to compile, rebuild, verify a build, or says phrases like "你自己编译验证下对不对", "帮我编译确认一下", "先 rebuild 看结果", or "用 EIDE 编一下". +description: Rebuilds an Embedded IDE for VS Code workspace through a Python runner and returns one complete JSON result. Use when the user asks to compile, rebuild, verify a build, or says phrases like "你自己编译验证下对不对", "帮我编译确认一下", "先 rebuild 看结果", or "用 EIDE 编一下". --- Use this skill when the user wants an EIDE project rebuilt from Codex. @@ -25,12 +25,18 @@ Run: python scripts/eide_rebuild.py rebuild ``` +Environment check: + +```powershell +python scripts/eide_rebuild.py doctor +``` + ## Result handling - Treat the runner as the source of truth. -- Read the `[EIDE-CLI]` protocol lines from `stdout`. -- Keep the full `compiler.log` content intact. -- Use exit code `0` for success, `6` for build failure, and the other exit codes for environment or bridge errors. +- Read one complete JSON object from `stdout`. +- Keep `compilerLog`, `steps`, `artifacts`, and `transcript` intact. +- Use exit code `0` for success, `6` for build failure, and the other exit codes for environment or tool errors. ## Subagent guidance diff --git a/skills/eide-rebuild/assets/eide-rebuild.cli-bridge-0.1.0.vsix b/skills/eide-rebuild/assets/eide-rebuild.cli-bridge-0.1.0.vsix deleted file mode 100644 index ec631f6dcd8758b09abab0a3e7a6d414faedf2a1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5793 zcmZ{obx@RD*v3g|5TqMqiItL+r8^du&ZQPuknR+uyBDM-q&uW!NkJMxkPuiJM3j^c z`Fv--dBgd>^URs&erBF?&0N=gpMQQ@U`#A>G&D5a-SE6f>Qfj-t!Yi9ZD#si?=Vq*SB{)Hf_;AY+JO{=RC_-eG&}@21zo?Ar|oEgR`R5=GEX(1Q3}A3Zld-KpV*$ zfh}Iim=5n2%X3-hF&DmDQ#e`?VzQJVXmr}nD7s11*-usK{P+g)E~Vv~!+&ni5!E&8 zO)H;OE6YW1Kv=(@6HmbY$T>wVjE<2kqA8@`kLK33)}%>7us@--jAz6)+DTbW^UmW_ zTu^y^Ddhpl_j`1$|9DR@Ngb^G?K=h&4UO`*cevkg=brYiF1$XT_I}PV7kgW{r&ppb zIC+u(VfSG@^%W!Cd9w+&!9H)aidK=Fm;vn%mUizS!#)phZ$S0ft$UjpG?RhJ*{Z)h z>In0OarS>CXk1008-Gi0-A)ibPLXWPypr!$x_SgQDVoK>C-t!&dR)*fiT5FjMtsmn znt*gG6-d}QJ&jx=P6Zp{DRKzZvGYOJ^Uj1f+y!1YU17>}zlp(r+$xJtpeVBE7YbNx zQe75Q61i!GX;qw7Qo!@i6Gh^{#c#XjtJr)fMAlztd%qP0(T}WN@IS6au z-p#o}dDK!LG7m>D5dUntOiAQSUNY~kZd!ZwRjXK?%uzG+Hg+NK8yoF{^V%#|m^tH= z7{d9iug`yX_knDPh8IPvpTx1*C!B7cjP=Ruj`?o4{UuUm4f&0n4zDz5*P%_he7Nl+ zx>5#L{gX(Z7U$@gGF97d%`4qDl)J$OqXbo(_!t2lD{Z)Fm8&lF6!0O~o_g5hn3PAO zlRY;{j{Ur7R~MIDdD9GS39Jz;Dz@U*L6k7$MY#!4KrxBklje2h{IXd75c8An2wAS} zO1pJ;FLaOn-{zX9etd=^K|>>z{-e2k|Fqe`^8=lu=aR??G8lX2Te_b04CG)D**JMT zoPpS@GLlk-)LFk1k(VQLjMKYnCAvlp~~|#KRFgG{07sex^MeMw`8fb;(K&fRS~k#V9FGF6Kb%oNcx|h% zsqXQPz`VL!f##J;afZ26=FL&pUeNe08?%SBqbzRUAhujUe4_UY5=Z#Jp++V+RS$?s^V48fml4Q9X&yvJ2r0=6s=yI&NR>`tEhJZ(;Ox)os@ttGd6P zX4@PBJZ`y@2s}OEkIlaAbfUPKYmNz8+-Y>#K)cQh9EZ-8OA;`OK&hkscNNJ2>f7a5 zFY^p%o;5&l86Z4gvkAGU*3uB2D%KC$SBfpjN<$17{obRUcmZHX`JMbY*ca1znviK_ z1c;0lg3G}m=Kgj=ey4n$>}q@&o;vXwA0Q>0#gmpm4~2|@f&k`pcAP_XS2m7g7aqYb zVr&{|dbTvBnF<4?3?J{>3LLK6DG@`8SphJGu+5vGcvzL+7bRWn%DCnjOaUTmGTg@S zwJ~85m3Mn#yZb+Dz=>8e?ezQ4++d>2>=eY#vYjXGPZO{y7Whi1 zFifA?5GxBoGhU#hxF5lFf?$^#32C@IR`Shi0~p&Sd`%Bwks!0%EF5i40;mGA#Wg9} zaCOTg!#0XSpQB_{;c03Ge3>X4tDkdXlyNwtH<6GIW$8h06;QLYXYOpvrQ@3&)jl ztn}FRloiiokFq@OVYJOu&f#*ba&_CM{!MjRQ}of*-=$){if(+jIWkjO{&0#{K}C~|QR0b5X3UmcLZZX+ z)b06R;JDt8i><7CW{#p}iud{3773&(-WV z2lQ6wP0E+41F)0tq*3dw^fr+`dn2#`dh%vWYBKjeJ)#ZQvbOrcL>$n5v}sPr2pr?} z)Z>_jTUqo;+PWUUx?#aIKZiWt!%YTXtg2!`_+ngtA-%1*)0@wdWW;HJLdT*e-k0e% zRe8S4Qxg96%MAtVVdWJ(5tw#`uJ)~3=9xl9CSCDS=DXTy)`F(sOiV)H24@132+?%` zzS1D^28+2VHyyT3=xBg;bJe#q$_rHC7#S7{)1lEZZ4pQ_I}KHgjrLUgCZxaayEXw^ zYcxQlrfCo7Dz;+smDRL*a9^6sHW)EBvnUQcpDGepQ+XUv+{dZ;bTP|AE#q1sPYna8 zGZNLSUMdS{kUjtW^VQuwJrOS|0YL^aB&M3_rl8VpcI`^9u!f~}1H}eYZp~$Y7FhEb z#4(BCkQPNC*`9%+8wLC<*=C+45Nd}DdqXp(2{TPew3(S@58S^AQ8k+h8r2>vH6OAc zw~at+eU+nQHk3mE*%(owC&Y`WqQ;11u96a$8j*Ll;Fk7^B2Yn$vV<5(nUn1;A z6J4J`GXl5xi93Dx>^m@uuM-9y%yXQdsuz};_m*C*%1sfGBg6Ae0QvYm<%eqVZ{aQYQh~7X(_<@MNT3U+77uAas&4G7OV5F z%SPK2G!4^bi(}chZ#bwqEc%#x{21=-2<95tEm9y)Gqu3wbI^;bWrp3{$toKM6Jo~m zNaBiv(682B(NX>x{;RVlnj1G7pPzE`BWDo1Kwqmo*b(!fTKy++t|c4R#p>edCN9su z)9TPxrp99FtM+;##XiGsRpw+f$v;rknL&$cRcvR&F(kQhDugni!dn2b3Z1xzYy`9e>;Mfr167 zttSX{!8j}?m*AJHyI~N;PjaVA{dSDke2PPG(xMByFVQ~J-lxT7f@(#Rl0ap%QAzQZ zm4oUB3Wn45FsRY|dj`$7*g#7H>&%onLB$OUrAxopCF#-eO~?(ao-H2>E4g)zh-h8B z9x&-z-$6DTOkP@r8rnxyLT$&)B~e#J=p=8d$h5zlbCH!)>$|8CqXkTzrB}VOneZob z`518^S_&4ZW)8457UZ_FmK@Cq-)LQSfdjMS~A7O+sF9`!j>BNkq(@%T*qcKQQ%$0|?a zje?_H&fp|mF>}MPYG-)NqoarTCq-=@qkd5KgrTtJ6vLxl-W@D*-0XC;2Yb-_v8JMY{3#9$e^6%(|t=A7MQx=1s@DS691y z>LvuJJ+s#T$P!PVAgNnbPs%r77uS){AwWBOfJ_J1s^sVUjdeIY;5*9*=pivz;`bNy zhh1rtF9CJ&1l=@H+u;~9XvIX-EXUgNVvh_Ysrbv$#n-F0G4+9#Mv8%inY2g7hn23Q z)qQ>q8BemWp3HvB{@j~Y7-u)e)m@>wJDY*vg6IoI1n5O3oqF(h7k_MD`r<>=+pWGp zPI*KPktNICp79RJ;BwL%^$@0XWM-bQWfQi*SnzqRfb49X>nhAUpXE0C=^F4*Nf^0i zQ}C^Lbmf=?4{%+`8-3M%A-Zcn??NnD4`ECbl&9$T*nE(29qS#c9 zNoXh0XS%Z(MtVA}P+3Q;D)yI!zCEDXdy?l#6;}5CXKiY5eQ#kydk7(JY7)zFn$Sai zkLT@!jbEKOzk6-P61DZ_>-%+IvQubY;xm!F4bABv(MTDv@c_@vPfZ->_Xs8Lq|*hl zUdt!79OOobrWe~VRGq};o};%ZH@YLmGoON3l9$!dVp6G*O>=e5|e6K|fA zg_@772tQMyqU98wmQ@{0zzuxFGxx$C|7YwLQ8j}q%YkqW5mN3gMtoZQP7W&SEs?W) zn==7N)zuzxUB_CMDzeRqCi0!h+eZU}X@hu*zCDtz{i@4rC6;>nlpi{F$d`8V{)Z8V{&D&rKWn&FD9aJ2nxvq)%QB0di*Wx1doc88eTSq; zPp!U)Z@LajQK6OYU}iu$Bj4!Tsh%6H47_6#1-=Y0+A8aEUBg4hrbh(Kzem0D%>yYO zJaYD8RW<$cWBepxF7=ak%|etu7!3roKaFn^413}i6t$=lv5a*zRGj3yZpmFjOh9wF zHPO52oZ(;bVtNS~-_23xuIta#v&e57#fhb~iEK|B^I*0I8&c z-oLW6;8k{Cw}*V0NLaLx?M{^JNF}IAJP%!K<^DAU;{7315!}s6T2Z;u9mEw6J|_5Y zRCbRd`~He5@_(V=-^7GZM^RQmLy_0nMlt!XoCt>w{qezFNm5u&(JWZgGI`(2%pHdj z!#=R&Ai-fuQFl?V?4*0FE}8#%k+(}snzM` zG(o0$@jgRbVT&{wxd?xn@~6h1WAl#&#qY%a_VUjVX@RjH{Ph#|?+5aG)+8zZ G`uZQ{gLO;* diff --git a/skills/eide-rebuild/scripts/eide_rebuild.py b/skills/eide-rebuild/scripts/eide_rebuild.py index c2313af..bcc9e1c 100644 --- a/skills/eide-rebuild/scripts/eide_rebuild.py +++ b/skills/eide-rebuild/scripts/eide_rebuild.py @@ -1,514 +1,7 @@ #!/usr/bin/env python3 from __future__ import annotations -import ctypes -import hashlib -import json -import locale -import os -import shutil -import subprocess -import sys -import tempfile -import time -import zipfile -from contextlib import contextmanager -from dataclasses import dataclass -from pathlib import Path -from typing import Any, Iterator - -if os.name == "nt": - import msvcrt - - -# --- Constants --- - -CLI_PREFIX = "[EIDE-CLI]" -BRIDGE_PUBLISHER = "eide-rebuild" -BRIDGE_NAME = "cli-bridge" -BRIDGE_ID = f"{BRIDGE_PUBLISHER}.{BRIDGE_NAME}" -BRIDGE_VERSION = "0.1.0" -BRIDGE_READY_TIMEOUT_MS = 30_000 -PIPE_CONNECT_TIMEOUT_MS = 5_000 - -if os.name == "nt": - KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True) - GENERIC_READ = 0x80000000 - GENERIC_WRITE = 0x40000000 - OPEN_EXISTING = 3 - ERROR_PIPE_BUSY = 231 - INVALID_HANDLE_VALUE = ctypes.c_void_p(-1).value - - -# --- Errors --- - -class ExitError(RuntimeError): - def __init__(self, exit_code: int, message: str) -> None: - super().__init__(message) - self.exit_code = exit_code - - -# --- Data --- - -@dataclass -class ProtocolState: - workspace: str = "" - target: str = "" - log_path: str = "" - result: str = "error" - duration_ms: str = "" - exit_code: int = 7 - compiler_log: str = "" - - -# --- Protocol --- - -def write_stderr_line(message: str) -> None: - sys.stderr.write(f"{message}\n") - - -def render_protocol(state: ProtocolState) -> str: - lines = [ - f"{CLI_PREFIX} begin workspace={state.workspace}", - f"{CLI_PREFIX} target={state.target}", - f"{CLI_PREFIX} logPath={state.log_path}", - f"{CLI_PREFIX} result={state.result}", - f"{CLI_PREFIX} durationMs={state.duration_ms}", - f"{CLI_PREFIX} compiler-log-begin", - ] - output = "\n".join(lines) + "\n" - if state.compiler_log: - output += state.compiler_log - if not state.compiler_log.endswith("\n") and not state.compiler_log.endswith("\r"): - output += "\n" - output += f"{CLI_PREFIX} compiler-log-end\n" - output += f"{CLI_PREFIX} end exitCode={state.exit_code}\n" - return output - - -def show_usage() -> None: - write_stderr_line("Usage: python eide_rebuild.py rebuild ") - - -# --- Paths --- - -def ensure_windows() -> None: - if os.name != "nt": - raise ExitError(7, "This runner supports Windows only.") - - -def get_local_appdata_root() -> Path: - local_appdata = os.environ.get("LOCALAPPDATA") - if local_appdata: - return Path(local_appdata) - return Path.home() / "AppData" / "Local" - - -def get_registration_root() -> Path: - override = os.environ.get("EIDE_REBUILD_REGISTRATION_ROOT") - if override: - return Path(override) - return Path.home() / ".vscode" / "eide-rebuild" / "registrations" - - -def get_registration_roots() -> list[Path]: - roots = [get_registration_root()] - legacy_root = get_local_appdata_root() / ("EIDE" + "_CLI") / "registrations" - if legacy_root not in roots: - roots.append(legacy_root) - return roots - - -def get_extensions_root() -> Path: - override = os.environ.get("EIDE_REBUILD_EXTENSIONS_ROOT") - if override: - return Path(override) - return Path.home() / ".vscode" / "extensions" - - -def resolve_full_path(path_value: str) -> Path: - path_obj = Path(path_value).expanduser() - if not path_obj.exists(): - raise ExitError(2, f"Path does not exist: {path_value}") - return path_obj.resolve() - - -def resolve_workspace_path(input_path: str) -> Path: - resolved_path = resolve_full_path(input_path) - if resolved_path.is_dir(): - workspace_files = sorted(resolved_path.glob("*.code-workspace")) - if len(workspace_files) == 1: - return workspace_files[0].resolve() - if not workspace_files: - raise ExitError(2, f"No .code-workspace file found in directory: {resolved_path}") - raise ExitError(2, f"Multiple .code-workspace files found in directory: {resolved_path}") - - if resolved_path.suffix.lower() == ".code-workspace": - return resolved_path - - raise ExitError(2, f"Expected a .code-workspace file or a project directory: {resolved_path}") - - -def normalize_workspace_path(workspace_path: Path | str) -> str: - return str(Path(workspace_path).resolve()).replace("/", "\\").lower() - - -def get_workspace_hash(workspace_path: Path | str) -> str: - normalized = normalize_workspace_path(workspace_path) - return hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16] - - -def get_registration_path(workspace_path: Path | str) -> Path: - return get_registration_root() / f"{get_workspace_hash(workspace_path)}.json" - - -def get_registration_paths(workspace_path: Path | str) -> list[Path]: - workspace_hash = get_workspace_hash(workspace_path) - return [root / f"{workspace_hash}.json" for root in get_registration_roots()] - - -def get_bridge_vsix_name() -> str: - return f"{BRIDGE_ID}-{BRIDGE_VERSION}.vsix" - - -def discover_vsix_path(script_dir: Path) -> Path: - override = os.environ.get("EIDE_REBUILD_BRIDGE_VSIX") - if override: - vsix_path = Path(override) - if vsix_path.exists(): - return vsix_path.resolve() - raise ExitError(7, f"Bundled bridge VSIX not found: {vsix_path}") - - candidates = [ - script_dir.parent / "assets" / get_bridge_vsix_name(), - script_dir.parent / "bridge" / "dist" / get_bridge_vsix_name(), - script_dir.parent.parent / "runtime" / "bridge" / "dist" / get_bridge_vsix_name(), - ] - - for candidate in candidates: - if candidate.exists(): - return candidate.resolve() - - raise ExitError(7, f"Bundled bridge VSIX not found: {candidates[-1]}") - - -# --- Commands --- - -def get_code_command_path() -> Path: - override = os.environ.get("EIDE_REBUILD_CODE_CMD") - if override: - code_path = Path(override) - if code_path.exists(): - return code_path.resolve() - raise ExitError(3, f"Configured VS Code CLI command does not exist: {code_path}") - - for command_name in ("code", "code.cmd"): - command_path = shutil.which(command_name) - if command_path: - return Path(command_path) - - fallback_paths = [ - get_local_appdata_root() / "Programs" / "Microsoft VS Code" / "bin" / "code.cmd", - Path(os.environ.get("ProgramFiles", "")) / "Microsoft VS Code" / "bin" / "code.cmd", - Path(os.environ.get("ProgramFiles(x86)", "")) / "Microsoft VS Code" / "bin" / "code.cmd", - ] - - for candidate in fallback_paths: - if str(candidate) and candidate.exists(): - return candidate.resolve() - - raise ExitError(3, "Cannot find VS Code CLI command 'code'.") - - -def run_code_command(command_path: Path, arguments: list[str]) -> subprocess.CompletedProcess[str]: - command_text = str(command_path) - if command_path.suffix.lower() in {".cmd", ".bat"}: - command = ["cmd.exe", "/d", "/c", command_text, *arguments] - else: - command = [command_text, *arguments] - - return subprocess.run( - command, - check=False, - text=True, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - - -# --- Bridge install --- - -def is_bridge_installed() -> bool: - extensions_root = get_extensions_root() - if not extensions_root.exists(): - return False - return any(extensions_root.glob(f"{BRIDGE_ID}-*")) - - -def install_bridge_from_vsix_fallback(vsix_path: Path) -> None: - extensions_root = get_extensions_root() - target_dir = extensions_root / f"{BRIDGE_ID}-{BRIDGE_VERSION}" - temp_root = Path(tempfile.mkdtemp(prefix="eide-rebuild-bridge-")) - archive_root = temp_root / "archive" - extension_root = archive_root / "extension" - - try: - archive_root.mkdir(parents=True, exist_ok=True) - extensions_root.mkdir(parents=True, exist_ok=True) - with zipfile.ZipFile(vsix_path, "r") as archive: - archive.extractall(archive_root) - if not (extension_root / "package.json").exists(): - raise ExitError(7, f"Bundled bridge VSIX is missing extension payload: {vsix_path}") - if target_dir.exists(): - shutil.rmtree(target_dir) - shutil.copytree(extension_root, target_dir) - finally: - shutil.rmtree(temp_root, ignore_errors=True) - - -def ensure_bridge_installed(code_command: Path, vsix_path: Path) -> bool: - if is_bridge_installed(): - return False - - result = run_code_command(code_command, ["--install-extension", str(vsix_path), "--force"]) - if result.returncode == 0 and is_bridge_installed(): - return True - - install_bridge_from_vsix_fallback(vsix_path) - if not is_bridge_installed(): - raise ExitError(7, f"Failed to install bridge extension from: {vsix_path}") - return True - - -# --- Registration --- - -def read_registration(workspace_path: Path) -> dict[str, Any] | None: - for registration_path in get_registration_paths(workspace_path): - if not registration_path.exists(): - continue - - try: - return json.loads(registration_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - registration_path.unlink(missing_ok=True) - - return None - - -def remove_registration(workspace_path: Path) -> None: - for registration_path in get_registration_paths(workspace_path): - registration_path.unlink(missing_ok=True) - - -# --- Named pipe --- - -@contextmanager -def open_named_pipe(pipe_name: str, timeout_ms: int) -> Iterator[Any]: - if os.name != "nt": - raise OSError("Named pipes require Windows.") - if not pipe_name: - raise OSError("Pipe name is missing.") - - pipe_path = fr"\\.\pipe\{pipe_name}" - deadline = time.monotonic() + (timeout_ms / 1000.0) - - while True: - handle = KERNEL32.CreateFileW( - pipe_path, - GENERIC_READ | GENERIC_WRITE, - 0, - None, - OPEN_EXISTING, - 0, - None, - ) - if handle != INVALID_HANDLE_VALUE: - file_descriptor = msvcrt.open_osfhandle(handle, os.O_BINARY) - stream = os.fdopen(file_descriptor, "r+b", buffering=0) - try: - yield stream - finally: - stream.close() - return - - error_code = ctypes.get_last_error() - remaining_ms = int(max(0, (deadline - time.monotonic()) * 1000)) - if remaining_ms <= 0: - raise ctypes.WinError(error_code) - if error_code == ERROR_PIPE_BUSY and KERNEL32.WaitNamedPipeW(pipe_path, remaining_ms): - continue - raise ctypes.WinError(error_code) - - -def read_pipe_line(stream: Any) -> str: - buffer = bytearray() - while True: - chunk = stream.read(1) - if not chunk: - break - if chunk == b"\n": - break - buffer.extend(chunk) - return buffer.decode("utf-8") - - -def test_registration_alive(registration: dict[str, Any]) -> bool: - pipe_name = str(registration.get("pipeName", "")) - try: - with open_named_pipe(pipe_name, 400): - return True - except OSError: - return False - - -def wait_for_registration(workspace_path: Path, timeout_ms: int) -> dict[str, Any]: - deadline = time.monotonic() + (timeout_ms / 1000.0) - while time.monotonic() < deadline: - registration = read_registration(workspace_path) - if registration: - if test_registration_alive(registration): - return registration - remove_registration(workspace_path) - time.sleep(0.5) - raise ExitError(4, "Timed out waiting for VS Code bridge registration.") - - -def invoke_bridge_request(registration: dict[str, Any], workspace_path: Path) -> dict[str, Any]: - request = json.dumps( - { - "requestId": f"req-{int(time.time() * 1000)}", - "action": "rebuild", - "workspacePath": str(workspace_path), - }, - separators=(",", ":"), - ) - pipe_name = str(registration.get("pipeName", "")) - - try: - with open_named_pipe(pipe_name, PIPE_CONNECT_TIMEOUT_MS) as stream: - stream.write(request.encode("utf-8") + b"\n") - stream.flush() - response_line = read_pipe_line(stream) - except OSError as error: - raise ExitError(4, f"Failed to communicate with VS Code bridge: {error}") from error - - if not response_line: - raise ExitError(4, "Bridge returned an empty response.") - - try: - return json.loads(response_line) - except json.JSONDecodeError as error: - raise ExitError(4, f"Bridge returned invalid JSON: {error}") from error - - -# --- Build flow --- - -def start_workspace_window(code_command: Path, workspace_path: Path, new_window: bool) -> None: - open_flag = "-n" if new_window else "-r" - result = run_code_command(code_command, [open_flag, str(workspace_path)]) - if result.returncode != 0: - raise ExitError(3, f"Failed to open workspace in VS Code: {workspace_path}") - - -def read_compiler_log_text(log_path: str) -> str: - path_obj = Path(log_path) - if not path_obj.exists(): - raise ExitError(8, f"compiler.log not found: {path_obj}") - try: - payload = path_obj.read_bytes() - except OSError as error: - raise ExitError(8, f"Failed to read compiler.log: {path_obj} ({error})") from error - - try: - return payload.decode("utf-8", errors="strict") - except UnicodeDecodeError: - return payload.decode(locale.getpreferredencoding(False), errors="replace") - - -def resolve_exit_code(response: dict[str, Any]) -> int: - if response.get("ok"): - return 0 - - error_code = str(response.get("errorCode", "")) - mapping = { - "BUILD_FAILED": 6, - "BUILD_NOT_STARTED": 5, - "BUSY": 4, - "WRONG_WORKSPACE": 4, - "EIDE_MISSING": 7, - "LOG_MISSING": 8, - } - return mapping.get(error_code, 7) - - -# --- Entry point --- - -def main(argv: list[str] | None = None) -> int: - ensure_windows() - arguments = list(sys.argv[1:] if argv is None else argv) - state = ProtocolState() - started_at = time.perf_counter() - - try: - if len(arguments) < 2: - show_usage() - raise ExitError(2, "Missing command or path.") - - command_name = arguments[0] - if command_name != "rebuild": - show_usage() - raise ExitError(2, f"Unsupported command: {command_name}") - - workspace_path = resolve_workspace_path(arguments[1]) - state.workspace = str(workspace_path) - - code_command = get_code_command_path() - vsix_path = discover_vsix_path(Path(__file__).resolve().parent) - bridge_installed_now = ensure_bridge_installed(code_command, vsix_path) - - registration = read_registration(workspace_path) - if not registration or not test_registration_alive(registration): - remove_registration(workspace_path) - start_workspace_window(code_command, workspace_path, new_window=bridge_installed_now) - registration = wait_for_registration(workspace_path, BRIDGE_READY_TIMEOUT_MS) - - response = invoke_bridge_request(registration, workspace_path) - exit_code = resolve_exit_code(response) - - if response.get("workspacePath"): - state.workspace = str(response["workspacePath"]) - if response.get("target"): - state.target = str(response["target"]) - if response.get("logPath"): - state.log_path = str(response["logPath"]) - if response.get("durationMs") is not None: - state.duration_ms = str(response["durationMs"]) - - state.exit_code = exit_code - state.result = "success" if exit_code == 0 else "failure" if exit_code == 6 else "error" - - if state.log_path and exit_code in {0, 6}: - state.compiler_log = read_compiler_log_text(state.log_path) - - if exit_code == 8: - write_stderr_line(f"compiler.log missing or unreadable: {state.log_path}") - elif not response.get("ok"): - write_stderr_line(str(response.get("message", ""))) - - except ExitError as error: - state.exit_code = error.exit_code - state.result = "error" - write_stderr_line(str(error)) - except Exception as error: - state.exit_code = 7 - state.result = "error" - write_stderr_line(str(error)) - finally: - if not state.duration_ms: - state.duration_ms = str(int((time.perf_counter() - started_at) * 1000)) - sys.stdout.write(render_protocol(state)) - - return state.exit_code +from eide_rebuild import main if __name__ == "__main__": diff --git a/skills/eide-rebuild/scripts/eide_rebuild/__init__.py b/skills/eide-rebuild/scripts/eide_rebuild/__init__.py new file mode 100644 index 0000000..616339c --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/__init__.py @@ -0,0 +1,170 @@ +from __future__ import annotations + +import json +import sys +import time +from pathlib import Path + +from .builder_params import generate_builder_params, write_builder_params +from .eide_model import EideModel, load_eide_model +from .executor import build_unify_builder_command, collect_output_files, rebuild_target, run_step +from .platform import current_platform, elapsed_ms, normalize_path, utc_now +from .project import ProjectInput, resolve_project_input +from .result_model import ( + RunResult, + StepResult, + TargetResult, + build_error_result, + build_run_result, + render_json_result, + write_run_result, +) +from .tools import ( + build_process_env, + check_unify_builder_runtime, + find_dotnet, + find_eide_extension_dir, + find_eide_tools_dir, + find_eide_utils_dir, + find_toolchain_root, + find_unify_builder, + run_doctor, +) + + +class ExitError(RuntimeError): + def __init__(self, exit_code: int, message: str, error_code: str) -> None: + super().__init__(message) + self.exit_code = exit_code + self.error_code = error_code + + +__all__ = [ + "EideModel", + "ExitError", + "ProjectInput", + "RunResult", + "StepResult", + "TargetResult", + "build_error_result", + "build_run_result", + "build_unify_builder_command", + "build_process_env", + "check_unify_builder_runtime", + "collect_output_files", + "current_platform", + "elapsed_ms", + "find_dotnet", + "find_eide_extension_dir", + "find_eide_tools_dir", + "find_eide_utils_dir", + "find_toolchain_root", + "find_unify_builder", + "generate_builder_params", + "load_eide_model", + "main", + "normalize_path", + "rebuild_target", + "render_json_result", + "resolve_project_input", + "run_doctor", + "run_step", + "utc_now", + "write_builder_params", + "write_run_result", +] + + +def _resolve_project_input_or_raise(input_path: str) -> ProjectInput: + try: + return resolve_project_input(input_path) + except RuntimeError as error: + raise ExitError(2, str(error), "MULTIPLE_WORKSPACES") from error + except FileNotFoundError as error: + missing_path = str(error) + error_code = "EIDE_YML_NOT_FOUND" if missing_path.replace("\\", "/").endswith("/.eide/eide.yml") else "PROJECT_PATH_NOT_FOUND" + raise ExitError(2, missing_path, error_code) from error + + +def _resolve_required_tools() -> tuple[str, str, str, str]: + try: + tool_paths = ( + find_dotnet(), + find_unify_builder(), + find_eide_tools_dir(), + find_toolchain_root(), + ) + runtime_check = check_unify_builder_runtime(tool_paths[0], tool_paths[1]) + if not runtime_check.get("ok", False): + raise ExitError(7, str(runtime_check.get("message") or "Unify builder runtime check failed."), "DOTNET_RUNTIME_MISSING") + return tool_paths + except FileNotFoundError as error: + raise ExitError(3, str(error), "TOOL_NOT_FOUND") from error + + +def main(argv: list[str] | None = None) -> int: + arguments = list(sys.argv[1:] if argv is None else argv) + started_mark = time.perf_counter() + started_at = utc_now() + + try: + if arguments and arguments[0] == "doctor": + doctor_result = run_doctor() + sys.stdout.write(json.dumps(doctor_result, ensure_ascii=False, indent=2) + "\n") + return int(doctor_result["exitCode"]) + + if len(arguments) < 2 or arguments[0] != "rebuild": + raise ExitError(2, "Missing command or path.", "WORKSPACE_NOT_FOUND") + + project_input = _resolve_project_input_or_raise(arguments[1]) + model = load_eide_model(project_input.eide_yml_path) + if not model.target_names: + raise ExitError(6, "No targets found in .eide/eide.yml.", "TARGETS_NOT_FOUND") + + dotnet_path, unify_builder_path, eide_tools_dir, toolchain_root = _resolve_required_tools() + + target_results: list[TargetResult] = [] + transcript_parts: list[str] = [] + for index, target_name in enumerate(model.target_names, start=1): + target_result = rebuild_target( + project_root=project_input.project_root, + project_name=model.project_name, + target_name=target_name, + target_index=index, + target_total=len(model.target_names), + dotnet_path=dotnet_path, + unify_builder_path=unify_builder_path, + eide_tools_dir=eide_tools_dir, + toolchain_root=toolchain_root, + ) + target_results.append(target_result) + if target_result.transcript: + transcript_parts.append(target_result.transcript) + + finished_at = utc_now() + run_result = build_run_result( + workspace_path=project_input.workspace_path, + project_root=project_input.project_root, + project_name=model.project_name, + platform_name=current_platform(), + target_names=model.target_names, + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(started_mark), + targets=target_results, + transcript="\n".join(transcript_parts), + ) + write_run_result(project_input.project_root / "build" / "rebuild_result.json", run_result) + sys.stdout.write(render_json_result(run_result)) + return run_result.exit_code + except ExitError as error: + finished_at = utc_now() + run_result = build_error_result(error, started_at, finished_at, elapsed_ms(started_mark)) + sys.stdout.write(render_json_result(run_result)) + return error.exit_code + except Exception as error: + finished_at = utc_now() + wrapped_error = ExitError(7, str(error), "INTERNAL_ERROR") + run_result = build_error_result(wrapped_error, started_at, finished_at, elapsed_ms(started_mark)) + sys.stdout.write(render_json_result(run_result)) + return wrapped_error.exit_code diff --git a/skills/eide-rebuild/scripts/eide_rebuild/builder_params.py b/skills/eide-rebuild/scripts/eide_rebuild/builder_params.py new file mode 100644 index 0000000..9b9eb97 --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/builder_params.py @@ -0,0 +1,191 @@ +from __future__ import annotations + +import copy +import json +import os +from pathlib import Path +from typing import Any + +import yaml + +from .eide_model import load_eide_model + + +SOURCE_EXTS = {".c", ".cpp", ".cc", ".cxx", ".s", ".a", ".o", ".lib", ".obj"} +_CPP_EXTS = {".cpp", ".cc", ".cxx"} +_ARM_TOOL_PREFIX = "arm-none-eabi-" + + +def _to_posix(path_value: Path | str) -> str: + return str(path_value).replace("\\", "/") + + +def _join_posix(root_dir: Path, path_value: str) -> str: + path = Path(path_value) + if path.is_absolute(): + return _to_posix(path) + return _to_posix(root_dir / path) + + +def _is_excluded(vpath: str, exclude_list: list[Any]) -> bool: + normalized = _to_posix(vpath).strip("/") + for entry in exclude_list: + excluded = _to_posix(str(entry)).strip("/") + if normalized == excluded or normalized.startswith(f"{excluded}/"): + return True + return False + + +def collect_sources(folder: dict[str, Any], exclude_list: list[Any], parent_vpath: str = "") -> list[str]: + sources: list[str] = [] + + for file_entry in folder.get("files") or []: + file_path = _to_posix(str((file_entry or {}).get("path", ""))) + if Path(file_path).suffix.lower() not in SOURCE_EXTS: + continue + file_name = str((file_entry or {}).get("name") or Path(file_path).name) + file_vpath = f"{parent_vpath}/{file_name}" + if _is_excluded(file_vpath, exclude_list): + continue + sources.append(file_path) + + for child in folder.get("folders") or []: + child_folder = child or {} + child_name = str(child_folder.get("name") or "") + child_vpath = f"{parent_vpath}/{child_name}" if child_name else parent_vpath + if _is_excluded(child_vpath, exclude_list): + continue + sources.extend(collect_sources(child_folder, exclude_list, child_vpath)) + + return sorted(sources) + + +def _pre_handle_options( + options: dict[str, Any], + source_list: list[str], + cpu_type: str, + fp_hardware: str, + arch_extensions: str, + scatter_path: str, + root_dir: Path, +) -> None: + global_options = options.setdefault("global", {}) + linker_options = options.setdefault("linker", {}) + + global_options["toolPrefix"] = _ARM_TOOL_PREFIX + linker_options["$toolName"] = "g++" if any(Path(source).suffix.lower() in _CPP_EXTS for source in source_list) else "gcc" + + fpu_suffix = {"single": "-sp", "double": "-dp"}.get(str(fp_hardware or "").lower(), "") + cpu_fpu_id = f"{str(cpu_type).lower()}{fpu_suffix}" + global_options["microcontroller-cpu"] = cpu_fpu_id + global_options["microcontroller-fpu"] = cpu_fpu_id + global_options["microcontroller-float"] = cpu_fpu_id + global_options["$arch-extensions"] = arch_extensions or "" + global_options["$clang-arch-extensions"] = "" + global_options["$armlink-arch-extensions"] = "" + + if str(linker_options.get("output-format") or "").lower() == "lib": + linker_options["$use"] = "linker-lib" + linker_options.pop("link-scatter", None) + elif scatter_path: + linker_options["link-scatter"] = [_join_posix(root_dir, scatter_path)] + + before_build_tasks = options.get("beforeBuildTasks") + after_build_tasks = options.get("afterBuildTasks") + options["beforeBuildTasks"] = list(before_build_tasks) if isinstance(before_build_tasks, list) else [] + options["afterBuildTasks"] = list(after_build_tasks) if isinstance(after_build_tasks, list) else [] + + +def _build_env(project_name: str, target: str, root_dir: Path, toolchain_loc: str) -> dict[str, str]: + root = _to_posix(root_dir) + is_windows = os.name == "nt" + dir_sep = "\\" if is_windows else "/" + path_sep = ";" if is_windows else ":" + out_dir = f"{root}/build/{target}" + return { + "workspaceFolder": root, + "workspaceFolderBasename": os.path.basename(root), + "OutDir": out_dir, + "OutDirRoot": "build", + "OutDirBase": f"build/{target}", + "ProjectName": project_name, + "ConfigName": target, + "ProjectRoot": root, + "ExecutableName": f"{root}/build/{target}/{project_name}", + "ChipPackDir": "", + "ChipName": "", + "SYS_Platform": "windows" if is_windows else "linux", + "SYS_DirSep": dir_sep, + "SYS_DirSeparator": dir_sep, + "SYS_PathSep": path_sep, + "SYS_PathSeparator": path_sep, + "SYS_EOL": "\n", + "ToolchainRoot": toolchain_loc, + } + + +def _load_source_params(eide_dir: Path, target: str) -> dict[str, Any]: + files_options_path = eide_dir / "files.options.yml" + if not files_options_path.exists(): + return {} + with files_options_path.open("r", encoding="utf-8") as stream: + files_opts = yaml.safe_load(stream) or {} + source_params = (((files_opts.get("options") or {}).get(target) or {}).get("files") or {}) + if isinstance(source_params, dict): + return source_params + return {} + + +def generate_builder_params(project_root: Path, target: str, eide_tools_dir: str, toolchain_root: str) -> dict[str, Any]: + root_dir = Path(project_root).resolve() + eide_dir = root_dir / ".eide" + model = load_eide_model(eide_dir / "eide.yml") + target_data = (model.payload.get("targets") or {})[target] + toolchain = str(target_data.get("toolchain") or "GCC") + toolchain_cfg = ((target_data.get("toolchainConfigMap") or {}).get(toolchain) or {}) + cpp_attrs = target_data.get("cppPreprocessAttrs") or {} + relative_sources = collect_sources(model.payload.get("virtualFolder") or {}, list(target_data.get("excludeList") or [])) + source_list = relative_sources + options = copy.deepcopy(toolchain_cfg.get("options") or {}) + + _pre_handle_options( + options, + source_list, + str(toolchain_cfg.get("cpuType") or ""), + str(toolchain_cfg.get("floatingPointHardware") or ""), + str(toolchain_cfg.get("archExtensions") or ""), + str(toolchain_cfg.get("scatterFilePath") or ""), + root_dir, + ) + + dump_path = f"build/{target}" + return { + "name": model.project_name, + "target": target, + "toolchain": toolchain, + "toolchainLocation": _to_posix(toolchain_root), + "toolchainCfgFile": _to_posix(f"{eide_tools_dir.rstrip('/').rstrip('\\\\')}/arm.gcc.model.json"), + "buildMode": "fast|multhread", + "showRepathOnLog": True, + "threadNum": os.cpu_count() or 4, + "rootDir": _to_posix(root_dir), + "dumpPath": dump_path, + "outDir": dump_path, + "incDirs": list(cpp_attrs.get("incList") or []), + "libDirs": list(cpp_attrs.get("libList") or []), + "defines": list(cpp_attrs.get("defineList") or []), + "sourceList": source_list, + "alwaysInBuildSources": [], + "sourceParams": _load_source_params(eide_dir, target), + "options": options, + "env": _build_env(model.project_name, target, root_dir, toolchain_root), + "sysPaths": [], + } + + +def write_builder_params(project_root: Path, target: str, params: dict[str, Any]) -> Path: + output_path = Path(project_root).resolve() / "build" / target / "builder.params" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8", newline="\n") as stream: + json.dump(params, stream, indent=4, ensure_ascii=False) + return output_path diff --git a/skills/eide-rebuild/scripts/eide_rebuild/eide_model.py b/skills/eide-rebuild/scripts/eide_rebuild/eide_model.py new file mode 100644 index 0000000..33d201e --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/eide_model.py @@ -0,0 +1,21 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import yaml + + +@dataclass +class EideModel: + project_name: str + target_names: list[str] + payload: dict[str, Any] + + +def load_eide_model(eide_yml_path: Path) -> EideModel: + with eide_yml_path.open("r", encoding="utf-8") as stream: + payload = yaml.safe_load(stream) or {} + targets = list((payload.get("targets") or {}).keys()) + return EideModel(project_name=str(payload.get("name", "")), target_names=targets, payload=payload) diff --git a/skills/eide-rebuild/scripts/eide_rebuild/executor.py b/skills/eide-rebuild/scripts/eide_rebuild/executor.py new file mode 100644 index 0000000..7f39bb7 --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/executor.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import locale +import os +import re +import subprocess +import time +from pathlib import Path +from typing import Any + +from .builder_params import generate_builder_params, write_builder_params +from .platform import elapsed_ms, normalize_path, utc_now +from .result_model import StepResult, TargetResult +from .tools import build_process_env, resolve_unify_builder_dll + + +def run_step( + kind: str, + name: str, + command: list[str] | str, + cwd: Path, + env: dict[str, str] | None = None, +) -> StepResult: + start_mark = time.perf_counter() + started_at = utc_now() + run_kwargs: dict[str, Any] = { + "cwd": cwd, + "capture_output": True, + "text": True, + } + if env: + merged_env = os.environ.copy() + merged_env.update(env) + run_kwargs["env"] = merged_env + if isinstance(command, str): + run_kwargs["shell"] = True + completed = subprocess.run(command, **run_kwargs) + finished_at = utc_now() + exit_code = int(completed.returncode) + return StepResult( + kind=kind, + name=name, + ok=exit_code == 0, + exit_code=exit_code, + error_code="OK" if exit_code == 0 else "STEP_FAILED", + message="" if exit_code == 0 else f"{name} failed with exit code {exit_code}.", + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(start_mark), + stdout=completed.stdout or "", + stderr=completed.stderr or "", + command=[command] if isinstance(command, str) else [str(part) for part in command], + cwd=normalize_path(cwd.resolve()), + ) + + +def build_unify_builder_command(dotnet_path: str, unify_builder_path: str, builder_params_path: str) -> list[str]: + unify_builder_dll = resolve_unify_builder_dll(unify_builder_path) + return [ + dotnet_path, + "exec", + "--roll-forward", + "Major", + unify_builder_dll, + "-p", + builder_params_path, + ] + + +def _make_step_result( + *, + kind: str, + name: str, + started_at: str, + finished_at: str, + duration_ms: int, + ok: bool, + error_code: str, + message: str = "", + stdout: str = "", + stderr: str = "", + command: list[str] | None = None, + cwd: Path | None = None, + exit_code: int = 0, +) -> StepResult: + return StepResult( + kind=kind, + name=name, + ok=ok, + exit_code=exit_code, + error_code=error_code, + message=message, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + stdout=stdout, + stderr=stderr, + command=list(command or []), + cwd=normalize_path(cwd.resolve()) if cwd is not None else "", + ) + + +def _read_text_file(path_value: Path) -> str: + payload = path_value.read_bytes() + try: + text = payload.decode("utf-8", errors="strict") + except UnicodeDecodeError: + text = payload.decode(locale.getpreferredencoding(False), errors="replace") + return text.replace("\r\n", "\n").replace("\r", "\n") + + +def collect_output_files(project_root: Path, target_name: str) -> list[dict[str, object]]: + build_dir = project_root / "build" / target_name + if not build_dir.exists(): + return [] + + suffix_map = { + ".bin": "bin", + ".hex": "hex", + ".a": "archive", + ".map": "map", + } + artifacts: list[dict[str, object]] = [] + for candidate in sorted(build_dir.iterdir()): + artifact_kind = suffix_map.get(candidate.suffix.lower()) + if artifact_kind is None or not candidate.is_file(): + continue + artifacts.append( + { + "path": normalize_path(candidate.resolve()), + "kind": artifact_kind, + "size": candidate.stat().st_size, + } + ) + return artifacts + + +def _build_transcript(steps: list[StepResult]) -> str: + parts: list[str] = [] + for step in steps: + if step.stdout: + parts.append(step.stdout.rstrip("\n")) + if step.stderr: + parts.append(step.stderr.rstrip("\n")) + return "\n".join(part for part in parts if part) + + +def _size_to_bytes(size_value: str, unit: str) -> int: + multipliers = { + "B": 1, + "KB": 1024, + "MB": 1024 * 1024, + } + return int(float(size_value) * multipliers[unit.upper()]) + + +def _parse_memory_regions(stdout: str) -> list[dict[str, object]]: + pattern = re.compile( + r"^\s*(?P[A-Za-z0-9_]+):\s+" + r"(?P\d+(?:\.\d+)?)\s+(?PB|KB|MB)\s+" + r"(?P\d+(?:\.\d+)?)\s+(?PB|KB|MB)\s+" + r"(?P\d+(?:\.\d+)?)%$" + ) + regions: list[dict[str, object]] = [] + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + match = pattern.match(raw_line.strip()) + if not match: + continue + regions.append( + { + "name": match.group("name"), + "used": _size_to_bytes(match.group("used"), match.group("used_unit")), + "total": _size_to_bytes(match.group("total"), match.group("total_unit")), + "percent": float(match.group("percent")), + "unit": "B", + } + ) + return regions + + +def _parse_source_stats(stdout: str) -> dict[str, int]: + stats_pattern = re.compile( + r"\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|\s*(?P\d+)\s*\|" + ) + jobs_pattern = re.compile(r"start compiling \(jobs:\s*(?P\d+)\)") + result: dict[str, int] = {} + + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw_line.strip() + match = stats_pattern.search(line) + if match: + result.update( + { + "cFiles": int(match.group("c")), + "cppFiles": int(match.group("cpp")), + "asmFiles": int(match.group("asm")), + "libObjFiles": int(match.group("libobj")), + "totalFiles": int(match.group("total")), + } + ) + continue + match = jobs_pattern.search(line) + if match: + result["jobs"] = int(match.group("jobs")) + + return result + + +def _parse_embedded_task_failures(stdout: str) -> list[dict[str, str]]: + task_pattern = re.compile(r"^>>\s*(?P.+?)\s+\[(?Pdone|failed)\]\s*$", re.IGNORECASE) + current_section = "build-task" + failures: list[dict[str, str]] = [] + + for raw_line in stdout.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + line = raw_line.strip() + lowered = line.lower() + if lowered == "[ info ] pre-build tasks ...": + current_section = "pre-build-task" + continue + if lowered == "[ info ] post-build tasks ...": + current_section = "post-build-task" + continue + if lowered == "[ info ] start outputting files ...": + current_section = "output-task" + continue + + match = task_pattern.match(line) + if match and match.group("status").lower() == "failed": + failures.append( + { + "kind": current_section, + "name": match.group("name").strip(), + } + ) + + return failures + + +def rebuild_target( + *, + project_root: Path, + project_name: str, + target_name: str, + target_index: int, + target_total: int, + dotnet_path: str, + unify_builder_path: str, + eide_tools_dir: str, + toolchain_root: str, +) -> TargetResult: + start_mark = time.perf_counter() + started_at = utc_now() + steps: list[StepResult] = [] + compiler_log = "" + compiler_log_path = project_root / "build" / target_name / "compiler.log" + builder_params_path = project_root / "build" / target_name / "builder.params" + stack_report_json_path = project_root / "build" / target_name / "stack_report.json" + stack_report_html_path = project_root / "build" / target_name / "stack_report.html" + builder_params_summary: dict[str, object] = {} + memory: list[dict[str, object]] = [] + source_stats: dict[str, int] = {} + error_code = "OK" + message = "" + exit_code = 0 + + try: + step_started_mark = time.perf_counter() + step_started_at = utc_now() + params = generate_builder_params(project_root, target_name, eide_tools_dir, toolchain_root) + builder_params_path = write_builder_params(project_root, target_name, params) + step_finished_at = utc_now() + builder_params_summary = { + "toolchain": params.get("toolchain", ""), + "threadNum": params.get("threadNum", 0), + "sourceCount": len(list(params.get("sourceList") or [])), + } + hook_env = {str(key): str(value) for key, value in dict(params.get("env") or {}).items()} + process_env = build_process_env(hook_env, toolchain_root) + steps.append( + _make_step_result( + kind="generate-builder-params", + name=f"generate {target_name} builder.params", + started_at=step_started_at, + finished_at=step_finished_at, + duration_ms=elapsed_ms(step_started_mark), + ok=True, + error_code="OK", + stdout=f"Generated: {normalize_path(builder_params_path.resolve())}\n", + ) + ) + + build_step = run_step( + kind="unify-builder", + name=f"build {target_name}", + command=build_unify_builder_command( + dotnet_path=dotnet_path, + unify_builder_path=unify_builder_path, + builder_params_path=normalize_path(builder_params_path.resolve()), + ), + cwd=project_root, + env=process_env, + ) + steps.append(build_step) + + if not build_step.ok: + error_code = "UNIFY_BUILDER_FAILED" + message = build_step.message or f"{target_name} build failed." + exit_code = 6 + elif compiler_log_path.exists(): + compiler_log = _read_text_file(compiler_log_path) + memory = _parse_memory_regions(build_step.stdout) + source_stats = _parse_source_stats(build_step.stdout) + embedded_failures = _parse_embedded_task_failures(build_step.stdout) + if embedded_failures: + first_failure = embedded_failures[0] + error_code_map = { + "pre-build-task": "PRE_BUILD_TASK_FAILED", + "post-build-task": "POST_BUILD_TASK_FAILED", + "output-task": "OUTPUT_TASK_FAILED", + } + error_code = error_code_map.get(first_failure["kind"], "BUILD_TASK_FAILED") + message = f"{first_failure['name']} failed inside unify_builder." + exit_code = 4 + else: + error_code = "COMPILER_LOG_MISSING" + message = f"compiler.log not found: {normalize_path(compiler_log_path)}" + exit_code = 8 + except Exception as error: + if exit_code == 0: + error_code = "BUILDER_PARAMS_GENERATION_FAILED" + message = str(error) + exit_code = 4 + + finished_at = utc_now() + artifacts = collect_output_files(project_root, target_name) + transcript = _build_transcript(steps) + return TargetResult( + name=target_name, + index=target_index, + total=target_total, + ok=exit_code == 0, + exit_code=exit_code, + error_code=error_code, + message=message, + started_at=started_at, + finished_at=finished_at, + duration_ms=elapsed_ms(start_mark), + builder_params_path=normalize_path(builder_params_path.resolve()), + builder_params_summary=builder_params_summary, + compiler_log_path=normalize_path(compiler_log_path.resolve()), + compiler_log=compiler_log, + stack_report_json_path=normalize_path(stack_report_json_path.resolve()) if stack_report_json_path.exists() else "", + stack_report_html_path=normalize_path(stack_report_html_path.resolve()) if stack_report_html_path.exists() else "", + source_stats=source_stats, + memory=memory, + artifacts=artifacts, + transcript=transcript, + steps=steps, + ) diff --git a/skills/eide-rebuild/scripts/eide_rebuild/platform.py b/skills/eide-rebuild/scripts/eide_rebuild/platform.py new file mode 100644 index 0000000..fbf9131 --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/platform.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import os +import time +from datetime import datetime, timezone +from pathlib import Path + + +def normalize_path(path_value: str | Path) -> str: + return str(Path(path_value)).replace("\\", "/") + + +def current_platform() -> str: + return "windows" if os.name == "nt" else "linux" + + +def utc_now() -> str: + return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") + + +def elapsed_ms(start_mark: float) -> int: + return int((time.perf_counter() - start_mark) * 1000) diff --git a/skills/eide-rebuild/scripts/eide_rebuild/project.py b/skills/eide-rebuild/scripts/eide_rebuild/project.py new file mode 100644 index 0000000..00af066 --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/project.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + + +@dataclass +class ProjectInput: + project_root: Path + workspace_path: str + eide_yml_path: Path + + +def resolve_project_input(input_path: str) -> ProjectInput: + path_obj = Path(input_path).expanduser().resolve() + if not path_obj.exists(): + raise FileNotFoundError(input_path) + + if path_obj.is_file() and path_obj.suffix.lower() == ".code-workspace": + project_root = path_obj.parent + eide_yml = project_root / ".eide" / "eide.yml" + if not eide_yml.exists(): + raise FileNotFoundError(str(eide_yml)) + return ProjectInput( + project_root=project_root, + workspace_path=str(path_obj).replace("\\", "/"), + eide_yml_path=eide_yml, + ) + + if path_obj.is_dir(): + workspace_files = sorted(path_obj.glob("*.code-workspace")) + if len(workspace_files) > 1: + raise RuntimeError(f"Expected one workspace file in {path_obj}") + eide_yml = path_obj / ".eide" / "eide.yml" + if eide_yml.exists(): + workspace_path = str(workspace_files[0].resolve()).replace("\\", "/") if workspace_files else "" + return ProjectInput( + project_root=path_obj, + workspace_path=workspace_path, + eide_yml_path=eide_yml, + ) + if len(workspace_files) == 1: + raise FileNotFoundError(str(eide_yml)) + + raise FileNotFoundError(input_path) diff --git a/skills/eide-rebuild/scripts/eide_rebuild/result_model.py b/skills/eide-rebuild/scripts/eide_rebuild/result_model.py new file mode 100644 index 0000000..2932233 --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/result_model.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +import json +from dataclasses import asdict, dataclass, field, is_dataclass +from pathlib import Path +from typing import Any + + +_KEY_MAP = { + "schema_version": "schemaVersion", + "exit_code": "exitCode", + "error_code": "errorCode", + "workspace_path": "workspacePath", + "project_root": "projectRoot", + "project_name": "projectName", + "started_at": "startedAt", + "finished_at": "finishedAt", + "duration_ms": "durationMs", + "target_names": "targetNames", + "source_stats": "sourceStats", + "builder_params_path": "builderParamsPath", + "builder_params_summary": "builderParamsSummary", + "compiler_log_path": "compilerLogPath", + "compiler_log": "compilerLog", + "stack_report_json_path": "stackReportJsonPath", + "stack_report_html_path": "stackReportHtmlPath", +} + + +@dataclass +class StepResult: + kind: str = "" + name: str = "" + ok: bool = False + exit_code: int = 0 + error_code: str = "" + message: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + stdout: str = "" + stderr: str = "" + command: list[str] = field(default_factory=list) + cwd: str = "" + + +@dataclass +class TargetResult: + name: str = "" + index: int = 0 + total: int = 0 + ok: bool = False + exit_code: int = 0 + error_code: str = "" + message: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + source_stats: dict[str, Any] = field(default_factory=dict) + builder_params_path: str = "" + builder_params_summary: dict[str, Any] = field(default_factory=dict) + compiler_log_path: str = "" + compiler_log: str = "" + stack_report_json_path: str = "" + stack_report_html_path: str = "" + transcript: str = "" + memory: list[dict[str, Any]] = field(default_factory=list) + artifacts: list[dict[str, Any]] = field(default_factory=list) + steps: list[StepResult] = field(default_factory=list) + + +@dataclass +class RunResult: + schema_version: str = "1" + ok: bool = False + exit_code: int = 7 + error_code: str = "INTERNAL_ERROR" + message: str = "" + mode: str = "rebuild-all" + platform: str = "" + workspace_path: str = "" + project_root: str = "" + project_name: str = "" + started_at: str = "" + finished_at: str = "" + duration_ms: int = 0 + summary: dict[str, Any] = field(default_factory=dict) + target_names: list[str] = field(default_factory=list) + transcript: str = "" + targets: list[TargetResult] = field(default_factory=list) + + +def _to_json_value(value: Any) -> Any: + if is_dataclass(value): + return _to_json_value(asdict(value)) + if isinstance(value, dict): + return {_KEY_MAP.get(key, key): _to_json_value(item) for key, item in value.items()} + if isinstance(value, list): + return [_to_json_value(item) for item in value] + return value + + +def render_json_result(result: RunResult) -> str: + return json.dumps(_to_json_value(result), ensure_ascii=False, indent=2) + "\n" + + +def _normalize_path(path_value: Path | str) -> str: + return str(Path(path_value).resolve()).replace("\\", "/") + + +def _current_platform() -> str: + import os + + return "windows" if os.name == "nt" else "linux" + + +def build_run_result( + workspace_path: str, + project_root: Path | str, + project_name: str, + platform_name: str, + target_names: list[str], + started_at: str, + finished_at: str, + duration_ms: int, + targets: list[TargetResult], + transcript: str, +) -> RunResult: + passed = sum(1 for target in targets if target.ok) + failed = len(targets) - passed + return RunResult( + ok=failed == 0, + exit_code=0 if failed == 0 else 6, + error_code="OK" if failed == 0 else "BUILD_FAILED", + message="" if failed == 0 else f"{failed} target(s) failed.", + mode="rebuild-all", + platform=platform_name, + workspace_path=workspace_path, + project_root=_normalize_path(project_root), + project_name=project_name, + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + summary={"discovered": len(target_names), "passed": passed, "failed": failed}, + target_names=target_names, + transcript=transcript, + targets=targets, + ) + + +def build_error_result(error: Exception, started_at: str, finished_at: str, duration_ms: int) -> RunResult: + error_code = getattr(error, "error_code", "INTERNAL_ERROR") + exit_code = getattr(error, "exit_code", 7) + return RunResult( + ok=False, + exit_code=exit_code, + error_code=error_code, + message=str(error), + mode="rebuild-all", + platform=_current_platform(), + started_at=started_at, + finished_at=finished_at, + duration_ms=duration_ms, + summary={"discovered": 0, "passed": 0, "failed": 0}, + ) + + +def write_run_result(output_path: Path | str, result: RunResult) -> None: + path = Path(output_path) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(render_json_result(result), encoding="utf-8", newline="\n") diff --git a/skills/eide-rebuild/scripts/eide_rebuild/tools.py b/skills/eide-rebuild/scripts/eide_rebuild/tools.py new file mode 100644 index 0000000..533374c --- /dev/null +++ b/skills/eide-rebuild/scripts/eide_rebuild/tools.py @@ -0,0 +1,309 @@ +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +from pathlib import Path + +from .platform import current_platform, normalize_path + + +def _resolve_existing_path(path_value: str, expect_dir: bool = False) -> str: + candidate = Path(path_value).expanduser() + if expect_dir: + if candidate.is_dir(): + return normalize_path(candidate.resolve()) + elif candidate.exists(): + return normalize_path(candidate.resolve()) + raise FileNotFoundError(str(candidate)) + + +def _path_if_dir(path_value: Path) -> str | None: + return normalize_path(path_value.resolve()) if path_value.is_dir() else None + + +def _version_key(path_value: Path) -> tuple[int, ...]: + match = re.search(r"(\d+(?:\.\d+)+)", path_value.name) + if not match: + return (0,) + return tuple(int(part) for part in match.group(1).split(".")) + + +def _iter_existing_dirs(paths: list[Path]) -> list[Path]: + result: list[Path] = [] + seen: set[str] = set() + for path in paths: + try: + resolved = path.expanduser().resolve() + except OSError: + continue + normalized = normalize_path(resolved) + if normalized in seen or not resolved.is_dir(): + continue + seen.add(normalized) + result.append(resolved) + return result + + +def _extension_roots() -> list[Path]: + roots: list[Path] = [] + override = os.environ.get("EIDE_REBUILD_VSCODE_EXTENSIONS_ROOT") + if override: + roots.append(Path(override)) + home_override = os.environ.get("EIDE_REBUILD_HOME") + if home_override: + roots.append(Path(home_override) / ".vscode" / "extensions") + roots.append(Path.home() / ".vscode" / "extensions") + return _iter_existing_dirs(roots) + + +def find_eide_extension_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_EXTENSION_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + candidates: list[Path] = [] + for root in _extension_roots(): + candidates.extend(path for path in root.glob("cl.eide-*") if path.is_dir()) + if not candidates: + raise FileNotFoundError("EIDE extension directory") + + best = sorted( + candidates, + key=lambda path: (_version_key(path), path.stat().st_mtime), + reverse=True, + )[0] + return normalize_path(best.resolve()) + + +def _candidate_model_dirs(base_dir: Path) -> list[Path]: + return [ + base_dir, + base_dir / "models", + base_dir / "res" / "data" / "models", + base_dir / "data" / "models", + ] + + +def _resolve_model_dir(base_dir: Path) -> str | None: + for candidate in _candidate_model_dirs(base_dir): + if (candidate / "arm.gcc.model.json").exists(): + return normalize_path(candidate.resolve()) + return None + + +def find_dotnet() -> str: + override = os.environ.get("EIDE_REBUILD_DOTNET") or os.environ.get("DOTNET_HOST_PATH") + if override: + return _resolve_existing_path(override) + command_path = shutil.which("dotnet") + if command_path: + return normalize_path(Path(command_path).resolve()) + raise FileNotFoundError("dotnet") + + +def find_eide_tools_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_TOOLS_DIR") or os.environ.get("EIDE_TOOLS_DIR") + if override: + resolved = _resolve_model_dir(Path(override).expanduser()) + if resolved: + return resolved + raise FileNotFoundError(str(Path(override).expanduser())) + + extension_dir = Path(find_eide_extension_dir()) + resolved = _resolve_model_dir(extension_dir) + if resolved: + return resolved + raise FileNotFoundError("EIDE tools directory") + + +def _platform_tool_dir(extension_dir: Path) -> Path: + platform_name = current_platform() + platform_folder = "win32" if platform_name == "windows" else platform_name + return extension_dir / "res" / "tools" / platform_folder + + +def find_unify_builder() -> str: + override = os.environ.get("EIDE_REBUILD_UNIFY_BUILDER") + if override: + return _resolve_existing_path(override) + + tools_override = os.environ.get("EIDE_REBUILD_EIDE_TOOLS_DIR") or os.environ.get("EIDE_TOOLS_DIR") + if tools_override: + direct_candidate = Path(tools_override).expanduser() / "unify_builder.dll" + if direct_candidate.exists(): + return normalize_path(direct_candidate.resolve()) + + unify_root = _platform_tool_dir(Path(find_eide_extension_dir())) / "unify_builder" + candidate_names = ["unify_builder.exe", "unify_builder.dll"] if current_platform() == "windows" else ["unify_builder.dll"] + for candidate_name in candidate_names: + candidate = unify_root / candidate_name + if candidate.exists(): + return normalize_path(candidate.resolve()) + raise FileNotFoundError("unify_builder.dll") + + +def resolve_unify_builder_dll(unify_builder_path: str) -> str: + path_obj = Path(unify_builder_path) + if path_obj.suffix.lower() == ".dll": + return normalize_path(path_obj.resolve()) + + sibling_dll = path_obj.with_suffix(".dll") + if sibling_dll.exists(): + return normalize_path(sibling_dll.resolve()) + + raise FileNotFoundError(str(sibling_dll)) + + +def find_eide_utils_dir() -> str: + override = os.environ.get("EIDE_REBUILD_EIDE_UTILS_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + utils_dir = _platform_tool_dir(Path(find_eide_extension_dir())) / "utils" + resolved = _path_if_dir(utils_dir) + if resolved: + return resolved + raise FileNotFoundError("EIDE utils directory") + + +def _toolchain_search_roots() -> list[Path]: + roots: list[Path] = [] + override = os.environ.get("EIDE_REBUILD_TOOLS_ROOT") + if override: + roots.append(Path(override)) + home_override = os.environ.get("EIDE_REBUILD_HOME") + if home_override: + roots.append(Path(home_override) / ".eide" / "tools") + roots.append(Path.home() / ".eide" / "tools") + return _iter_existing_dirs(roots) + + +def find_toolchain_root() -> str: + override = os.environ.get("EIDE_REBUILD_TOOLCHAIN_ROOT") or os.environ.get("COMPILER_DIR") + if override: + return _resolve_existing_path(override, expect_dir=True) + + candidates: list[Path] = [] + for root in _toolchain_search_roots(): + for gcc_path in root.glob("**/bin/arm-none-eabi-gcc.exe"): + candidates.append(gcc_path.parent.parent) + for gcc_path in root.glob("**/bin/arm-none-eabi-gcc"): + candidates.append(gcc_path.parent.parent) + if not candidates: + raise FileNotFoundError("toolchain root") + + best = sorted( + candidates, + key=lambda path: (_version_key(path), path.stat().st_mtime), + reverse=True, + )[0] + return normalize_path(best.resolve()) + + +def build_process_env(extra_env: dict[str, str] | None, toolchain_root: str) -> dict[str, str]: + env = os.environ.copy() + if extra_env: + env.update({str(key): str(value) for key, value in extra_env.items()}) + + path_parts: list[str] = [] + try: + path_parts.append(str(Path(find_eide_utils_dir()).resolve())) + except FileNotFoundError: + pass + + toolchain_bin = Path(toolchain_root) / "bin" + if toolchain_bin.is_dir(): + path_parts.append(str(toolchain_bin.resolve())) + + existing_path = env.get("PATH", "") + if existing_path: + path_parts.append(existing_path) + env["PATH"] = os.pathsep.join(path_parts) + return env + + +def check_unify_builder_runtime(dotnet_path: str, unify_builder_path: str) -> dict[str, object]: + unify_builder_dll = resolve_unify_builder_dll(unify_builder_path) + runtime_config = Path(unify_builder_dll).with_suffix(".runtimeconfig.json") + framework_name = "" + framework_version = "" + if runtime_config.exists(): + payload = json.loads(runtime_config.read_text(encoding="utf-8")) + runtime_options = payload.get("runtimeOptions") or {} + framework = runtime_options.get("framework") or {} + framework_name = str(framework.get("name") or "") + framework_version = str(framework.get("version") or "") + + completed = subprocess.run( + [dotnet_path, "exec", "--roll-forward", "Major", unify_builder_dll, "-v"], + capture_output=True, + text=True, + check=False, + ) + ok = completed.returncode == 0 + message = "" + if not ok: + message = completed.stderr.strip() or completed.stdout.strip() or "Failed to start unify_builder." + + installed_versions: list[str] = [] + runtimes = subprocess.run( + [dotnet_path, "--list-runtimes"], + capture_output=True, + text=True, + check=False, + ) + if runtimes.returncode == 0 and framework_name: + for raw_line in runtimes.stdout.splitlines(): + match = re.match(r"^(?P\S+)\s+(?P\d+\.\d+\.\d+)", raw_line.strip()) + if not match or match.group("name") != framework_name: + continue + installed_versions.append(match.group("version")) + + return { + "ok": ok, + "requiredFramework": framework_name, + "requiredVersion": framework_version, + "installedVersions": installed_versions, + "message": message, + "launchCommand": [dotnet_path, "exec", "--roll-forward", "Major", unify_builder_dll, "-v"], + } + + +def run_doctor() -> dict[str, object]: + tools: dict[str, str] = {} + errors: list[str] = [] + runtime_info: dict[str, object] = {"ok": True} + + checks = { + "dotnet": find_dotnet, + "eideExtensionDir": find_eide_extension_dir, + "eideToolsDir": find_eide_tools_dir, + "eideUtilsDir": find_eide_utils_dir, + "unifyBuilder": find_unify_builder, + "toolchainRoot": find_toolchain_root, + } + + for name, getter in checks.items(): + try: + tools[name] = getter() + except FileNotFoundError as error: + errors.append(f"{name}: {error}") + + if "dotnet" in tools and "unifyBuilder" in tools: + runtime_info = check_unify_builder_runtime(tools["dotnet"], tools["unifyBuilder"]) + if not runtime_info.get("ok", False): + errors.append(str(runtime_info.get("message") or "Unify builder runtime check failed.")) + + ok = not errors + return { + "ok": ok, + "exitCode": 0 if ok else 3, + "errorCode": "OK" if ok else "TOOL_NOT_FOUND", + "message": "" if ok else "; ".join(errors), + "platform": current_platform(), + "tools": tools, + "runtime": runtime_info, + }