diff --git a/README.md b/README.md index 9caadc4..176089c 100644 --- a/README.md +++ b/README.md @@ -112,6 +112,24 @@ If you already have a project directory with GDS files: --- +## Double-wide openframe projects + +Double-wide projects (`type: double_wide`, detected from +`gds/double_wide_openframe_project_wrapper.gds`) use the same commands as +openframe projects, with these differences: + +- `cf setup` clones the double-wide harness + ([chipfoundry/double_wide_openframe](https://github.com/chipfoundry/double_wide_openframe)) + into `caravel/` instead of caravel-lite, and skips the management core wrapper. + The harness ref comes from `harness_refs.double_wide` in `versions.json` (default `main`). +- `cf precheck` passes `caravel/` to cf-precheck as the golden root, since the + precheck image only ships the standard caravel. +- `cf verify` runs caravel-cocotb in OpenFrame mode. The harness supplies the + 63-pad testbench (`verilog/rtl/toplevel_cocotb.v`) and include lists. +- `cf gpio-config` is not used: the user wrapper drives every pad control signal. + +--- + ## Project Structure Requirements Your project directory **must** contain: @@ -120,6 +138,7 @@ Your project directory **must** contain: - `user_project_wrapper.gds` (for digital projects) - `user_analog_project_wrapper.gds` (for analog projects) - `openframe_project_wrapper.gds` (for openframe projects) + - `double_wide_openframe_project_wrapper.gds` (for double-wide openframe projects) - **Note**: Both compressed (`.gz`) and uncompressed (`.gds`) files are supported - `verilog/rtl/user_defines.v` (required for digital/analog) - `.cf/project.json` (optional; will be created/updated automatically) @@ -617,7 +636,7 @@ Preconditions: - Project has a GitHub repo URL (set via `cf init`, shown in the portal). - The ChipFoundry GitHub App is installed on that repo (prompted in the portal). - Your local `HEAD` has been pushed to `origin` on some branch (`git push`). -- Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (when not an openframe project), and `.cf/project.json` (when tracked). +- Push-critical files at `HEAD` are clean: wrapper GDS, `verilog/rtl/user_defines.v` (digital and analog projects only), and `.cf/project.json` (when tracked). What happens: 1. `cf push --remote` resolves your local HEAD SHA and checks it is reachable from a remote ref. @@ -641,8 +660,8 @@ both port 22 and GitHub. Preconditions: - Project is linked to the platform (`cf init` or `cf link`) and you are logged in. - Wrapper GDS exists locally under `gds/` (one of `user_project_wrapper.gds[.gz]`, - `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`). -- For non-openframe projects, `verilog/rtl/user_defines.v` is also uploaded when present. + `user_analog_project_wrapper.gds[.gz]`, `openframe_project_wrapper.gds[.gz]`, `double_wide_openframe_project_wrapper.gds[.gz]`). +- For digital and analog projects, `verilog/rtl/user_defines.v` is also uploaded when present. - Outbound HTTPS to `*.s3.us-east-2.amazonaws.com` is allowed. What happens: @@ -863,7 +882,7 @@ The CLI tracks your project submission state through the `submission_state` fiel 1. **File Collection:** - Checks for required GDS and Verilog files - - Auto-detects project type (digital, analog, openframe) based on GDS file name + - Auto-detects project type (digital, analog, openframe, double_wide) based on GDS file name - **Supports both compressed and uncompressed GDS files** 2. **Configuration:** @@ -932,7 +951,7 @@ cf config # Initialize project (in your project directory) cf init # Project name (detected: my_awesome_project): -# Project type (digital/analog/openframe) (detected: digital): +# Project type (digital/analog/openframe/double_wide) (detected: digital): # Upload your project cf push diff --git a/chipfoundry_cli/librelane_run.py b/chipfoundry_cli/librelane_run.py index 1ddf5a4..0ec1ce3 100644 --- a/chipfoundry_cli/librelane_run.py +++ b/chipfoundry_cli/librelane_run.py @@ -228,6 +228,146 @@ def new_run_tag() -> str: return datetime.now().strftime("%y_%m_%d_%H_%M") +def double_wide_flake_dir(project_root: Path) -> Optional[Path]: + """The double_wide project's own flake.nix dir, pinning LibreLane 3. + + double_wide ships its own flake (not cf-cli's `chipfoundry/openlane-2` + flake used by every other project type) because the flat wrapper needs + LibreLane 3's native PDN-to-pad routing. Returns None if the project + doesn't have one (e.g. it predates this, or a user deleted it). + """ + if (project_root / "flake.nix").is_file(): + return project_root + return None + + +def double_wide_nix_available(flake_dir: Path, timeout: float = 20) -> bool: + """Check that `nix develop` can resolve the project's pinned LibreLane 3 flake.""" + if shutil.which("nix") is None: + return False + try: + result = subprocess.run( + ["nix", "flake", "metadata", str(flake_dir), "--json"], + capture_output=True, + timeout=timeout, + ) + return result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + return False + + +def build_double_wide_librelane_command( + *, + project_root: Path, + flake_dir: Path, + config_file: Path, + pdk_root: Path, + pdk: str, + tag: str, + from_step: Optional[str] = None, + open_in_openroad: bool = False, + open_in_klayout: bool = False, +) -> Tuple[List[str], Dict[str, str]]: + """Build the `nix develop` command that runs LibreLane 3 on a flat double_wide wrapper. + + No `--manual-pdk`/`--pdk-root`/`--pdk` flags: LibreLane 3's default + (Ciel-managed) PDK resolution auto-fetches the pinned PDK commit into + `PDK_ROOT` on its own, the same project-local `dependencies/pdks` root + every other project type's PDK already lives under (Ciel keys PDK + versions by commit, so the two coexist without conflict). + """ + env = os.environ.copy() + env.update( + { + "PROJECT_ROOT": str(project_root), + "PDK_ROOT": str(pdk_root), + "PDK": pdk, + "LIBRELANE_RUN_TAG": tag, + } + ) + + librelane_args = [] + if open_in_openroad: + librelane_args.extend(["--flow", "OpenInOpenROAD"]) + elif open_in_klayout: + librelane_args.extend(["--flow", "OpenInKLayout"]) + librelane_args.extend(["--ef-save-views-to", str(project_root), "--run-tag", tag]) + if not from_step and not (open_in_openroad or open_in_klayout): + librelane_args.append("--overwrite") + if from_step: + librelane_args.extend(["--from", from_step]) + librelane_args.append(str(config_file)) + + cmd = ["nix", "develop", str(flake_dir), "--command", "librelane", *librelane_args] + return cmd, env + + +def get_double_wide_valid_from_steps( + *, flake_dir: Path, config_file: Path, working_dir: Path +) -> Tuple[Optional[List[str]], Optional[str]]: + """Resolve valid `--from` step names for a double_wide config via LibreLane 3.""" + script = ( + "import json, sys\n" + "from librelane.config import Config\n" + "from librelane.flows import Flow, SequentialFlow\n" + "cfg = sys.argv[1]\n" + "target = Flow.factory.get('Classic')\n" + "meta = Config.get_meta(cfg)\n" + "if meta:\n" + " if isinstance(meta.flow, str):\n" + " found = Flow.factory.get(meta.flow)\n" + " if found is None:\n" + " raise RuntimeError(f\"Unknown flow '{meta.flow}' in config metadata\")\n" + " target = found\n" + " elif isinstance(meta.flow, list):\n" + " target = SequentialFlow.make(meta.flow)\n" + " if meta.substituting_steps is not None:\n" + " if meta.flow is None:\n" + " raise RuntimeError('substituting_steps is set but flow is not defined')\n" + " if not issubclass(target, SequentialFlow):\n" + " raise RuntimeError('substituting_steps requires a sequential flow')\n" + " target = target.Substitute(meta.substituting_steps)\n" + "steps = []\n" + "seen = set()\n" + "for step in getattr(target, 'Steps', []) or []:\n" + " step_id = getattr(step, 'id', None)\n" + " if not step_id or step_id in seen:\n" + " continue\n" + " seen.add(step_id)\n" + " steps.append(step_id)\n" + "print('__CF_STEPS__' + json.dumps({'steps': steps}))\n" + ) + try: + result = subprocess.run( + [ + "nix", + "develop", + str(flake_dir), + "--command", + "python3", + "-c", + script, + str(config_file), + ], + cwd=str(working_dir), + capture_output=True, + text=True, + timeout=120, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return None, str(exc) + if result.returncode != 0: + return None, (result.stderr or result.stdout or "unknown error").strip() + marker = "__CF_STEPS__" + for line in result.stdout.splitlines(): + if line.startswith(marker): + try: + return json.loads(line[len(marker):])["steps"], None + except Exception as exc: + return None, str(exc) + return None, "LibreLane did not report a step list" + + def find_synth_stat_json(flow_root: Path, macro: str, run_tag: str) -> Optional[Path]: """Locate Yosys stat.json written by a synth-only or full LibreLane run.""" run_dir = flow_root / macro / "runs" / run_tag diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index deacfd5..c6f1191 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -18,7 +18,10 @@ get_gpio_config_from_project_json, save_gpio_config_to_project_json, GPIO_MODES, GPIO_MODE_DESCRIPTIONS, GPIO_HEX_TO_MODE, detect_github_repo_url, get_head_commit_sha, + PROJECT_TYPES, HARNESS_REPOS, is_padframe_only, harness_ref, + read_project_type, DOUBLE_WIDE_LIBRELANE_REV, ) +from chipfoundry_cli import librelane_run import os from pathlib import Path from rich.console import Console @@ -33,6 +36,7 @@ import shutil import signal import difflib +import shlex # Textual imports for GPIO grid UI from textual.app import App, ComposeResult @@ -568,11 +572,16 @@ def _merged(key_local: str, key_platform: Optional[str] = None) -> Optional[str] name = _prompt_with_default("Project name", current_name, default_name) or default_name project_type = _prompt_with_default( - "Project type (digital/analog/openframe)", current_type, detected_type + f"Project type ({'/'.join(PROJECT_TYPES)})", current_type, detected_type ) if not project_type: console.print("[red]Project type is required.[/red]") raise click.Abort() + if project_type not in PROJECT_TYPES: + console.print( + f"[red]Unknown project type '{project_type}'. Expected one of: {', '.join(PROJECT_TYPES)}.[/red]" + ) + raise click.Abort() if description is not None: description_val: Optional[str] = description or None @@ -780,10 +789,11 @@ def gpio_config(project_root, view): project_data = json.load(f) project_type = project_data.get('project', {}).get('type', 'digital') - # For openframe, GPIO config is not needed - if project_type == 'openframe': - console.print("[red]GPIO configuration is not available for openframe projects.[/red]") - console.print("[yellow]Openframe projects do not use user_defines.v.[/yellow]") + if is_padframe_only(project_type): + console.print(f"[red]GPIO configuration is not available for {project_type} projects.[/red]") + console.print( + f"[yellow]{project_type} projects do not use user_defines.v; the user wrapper drives the pad controls.[/yellow]" + ) raise click.Abort() user_defines_path = project_root / 'verilog' / 'rtl' / 'user_defines.v' @@ -1772,8 +1782,8 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s """Return [(rel_path, abs_path, size, kind)] for files the platform accepts via --https. - Picks exactly one wrapper GDS (analog/digital/openframe) and, for - non-openframe projects, ``verilog/rtl/user_defines.v`` if it exists. + Picks exactly one wrapper GDS (analog/digital/openframe/double_wide) and, + for caravel-based projects, ``verilog/rtl/user_defines.v`` if it exists. Raises FileNotFoundError if no wrapper is present or ValueError if multiple are. """ @@ -1790,7 +1800,8 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s raise FileNotFoundError( "No wrapper GDS found (expected one of gds/user_project_wrapper.gds[.gz], " "gds/user_analog_project_wrapper.gds[.gz], " - "gds/openframe_project_wrapper.gds[.gz])." + "gds/openframe_project_wrapper.gds[.gz], " + "gds/double_wide_openframe_project_wrapper.gds[.gz])." ) if len(hits) > 1: paths = ", ".join(h[1] for h in hits) @@ -1804,7 +1815,7 @@ def _collect_push_candidates(project_root: Path) -> List[Tuple[str, Path, int, s (wrapper_rel, abs_wrapper, abs_wrapper.stat().st_size, "wrapper") ] - if kind != "openframe": + if not is_padframe_only(kind): ud = project_root / USER_DEFINES_REL if ud.is_file(): results.append((USER_DEFINES_REL, ud, ud.stat().st_size, "aux")) @@ -2169,7 +2180,7 @@ def push(project_root, sftp_host, sftp_username, sftp_key, project_id, project_n upload_map = { ".cf/project.json": project_json_path, } - if detected_type != "openframe": + if not is_padframe_only(detected_type): upload_map["verilog/rtl/user_defines.v"] = collected.get("verilog/rtl/user_defines.v") # Add the appropriate GDS file based on what was collected @@ -3156,8 +3167,9 @@ def setup(project_root, repo_owner, repo_name, branch, pdk, caravel_lite, except (json.JSONDecodeError, IOError): pass # Use default if we can't read it - is_openframe = project_type == 'openframe' - + padframe_only = is_padframe_only(project_type) + harness_repo = HARNESS_REPOS.get(project_type) + had_errors = False def _error_text(err): @@ -3186,8 +3198,8 @@ def maybe_abort_no_space(err, step_label): # If in "only" mode, only install what's specified # If not in "only" mode, install everything install_caravel = only_caravel or not only_mode - # MCW is not used for openframe projects - install_mcw = (only_mcw or not only_mode) and not is_openframe + # MCW is not used by bare-padframe projects + install_mcw = (only_mcw or not only_mode) and not padframe_only install_openlane = only_openlane or not only_mode install_pdk = only_pdk or not only_mode install_timing = only_timing or not only_mode @@ -3201,11 +3213,14 @@ def maybe_abort_no_space(err, step_label): f"Repository: [yellow]{repo_owner}/{repo_name}@{branch}[/yellow]", f"PDK: [yellow]{pdk}[/yellow]", f"Project type: [yellow]{project_type}[/yellow]", - f"Caravel variant: [yellow]{'caravel-lite' if caravel_lite else 'caravel'}[/yellow]", ] - - if is_openframe: - config_lines.append("[dim]MCW not needed for openframe projects[/dim]") + if harness_repo: + config_lines.append(f"Harness: [yellow]{harness_repo}[/yellow]") + else: + config_lines.append(f"Caravel variant: [yellow]{'caravel-lite' if caravel_lite else 'caravel'}[/yellow]") + + if padframe_only: + config_lines.append(f"[dim]MCW not needed for {project_type} projects[/dim]") if only_mode: installing = [] @@ -3254,21 +3269,26 @@ def maybe_abort_no_space(err, step_label): deps_dir.mkdir(exist_ok=True) console.print(f"[green]✓[/green] Dependencies directory ready at {deps_dir}") - # Step 2: Install Caravel/Caravel-Lite + # Step 2: Install Caravel/Caravel-Lite, or the project type's own harness if install_caravel: - console.print("\n[bold]Step 2:[/bold] Installing Caravel...") caravel_dir = project_root_path / 'caravel' - caravel_name = 'caravel-lite' if caravel_lite else 'caravel' - - # Determine MPW tag based on PDK - if pdk not in mpw_tags: - console.print(f"[red]✗[/red] PDK '{pdk}' not found in version configuration") - console.print(f"[yellow]Available PDKs: {', '.join(mpw_tags.keys())}[/yellow]") - raise click.Abort() - mpw_tag = mpw_tags[pdk] - - # Caravel repository URL - caravel_repo = f'https://github.com/chipfoundry/{caravel_name}' + if harness_repo: + console.print("\n[bold]Step 2:[/bold] Installing harness...") + caravel_name = harness_repo.rstrip('/').rsplit('/', 1)[-1] + caravel_repo = harness_repo + mpw_tag = harness_ref(project_type, versions) + else: + console.print("\n[bold]Step 2:[/bold] Installing Caravel...") + caravel_name = 'caravel-lite' if caravel_lite else 'caravel' + + # Determine MPW tag based on PDK + if pdk not in mpw_tags: + console.print(f"[red]✗[/red] PDK '{pdk}' not found in version configuration") + console.print(f"[yellow]Available PDKs: {', '.join(mpw_tags.keys())}[/yellow]") + raise click.Abort() + mpw_tag = mpw_tags[pdk] + + caravel_repo = f'https://github.com/chipfoundry/{caravel_name}' # Check if already installed with correct version is_correct_version, current_version = check_version_installed(caravel_dir, mpw_tag) @@ -3305,10 +3325,9 @@ def maybe_abort_no_space(err, step_label): console.print(f"[dim]{e.stderr}[/dim]") # Step 3: Install Management Core Wrapper - # Show message if user explicitly requested MCW but project is openframe - if only_mcw and is_openframe: + if only_mcw and padframe_only: console.print("\n[bold]Step 3:[/bold] Installing Management Core Wrapper...") - console.print("[yellow]⚠[/yellow] MCW is not used for openframe projects, skipping...") + console.print(f"[yellow]⚠[/yellow] MCW is not used for {project_type} projects, skipping...") elif install_mcw: console.print("\n[bold]Step 3:[/bold] Installing Management Core Wrapper...") mcw_dir = project_root_path / 'mgmt_core_wrapper' @@ -3738,6 +3757,8 @@ def maybe_abort_no_space(err, step_label): console.print("[bold green]Installation complete![/bold green]") else: console.print("[bold green]Setup complete![/bold green]") + if had_errors: + sys.exit(1) def _is_nix_toolchain_noise_line(line: str) -> bool: @@ -3945,6 +3966,181 @@ def _queue_and_maybe_poll_remote_job( client.close() +def _harden_double_wide( + *, + macro: str, + macro_dir: Path, + config_file: Path, + project_root_path: Path, + tag: Optional[str], + from_step: Optional[str], + open_in_openroad: bool, + open_in_klayout: bool, + pdk: Optional[str], + use_docker: bool, + dry_run: bool, + list_from_steps: bool, +): + """Harden a `double_wide` macro with LibreLane 3 (native PDN-to-pad routing). + + double_wide bypasses the OpenLane-2/Docker-venv path used by every other + project type: its upstream template ships its own `flake.nix` pinning + LibreLane 3, whose native PDN generator (`PDN_ENABLE_PINS`) reaches the + padframe-aligned power pins directly, so no bridging "connection" macro + or cf-cli-managed OpenLane install is needed. + """ + gui_mode_count = int(open_in_openroad) + int(open_in_klayout) + + if use_docker: + console.print("[red]✗[/red] double_wide hardening does not support --use-docker") + console.print( + "[yellow]double_wide uses the project's own Nix flake (LibreLane 3). " + "Install Nix: https://librelane.readthedocs.io[/yellow]" + ) + raise click.Abort() + + flake_dir = librelane_run.double_wide_flake_dir(project_root_path) + if flake_dir is None: + console.print(f"[red]✗[/red] No flake.nix found in {project_root_path}") + console.print("[yellow]double_wide projects must ship a flake.nix pinning LibreLane 3[/yellow]") + raise click.Abort() + + if not librelane_run.double_wide_nix_available(flake_dir): + console.print("[red]✗[/red] Nix not available or cannot resolve the project's LibreLane 3 flake") + console.print("[yellow]Install Nix from: https://librelane.readthedocs.io[/yellow]") + raise click.Abort() + + pdk_root = librelane_run.pdk_root_dir(project_root_path) + pdk = librelane_run.detect_pdk(project_root_path, pdk) + + valid_from_steps, valid_from_steps_error = librelane_run.get_double_wide_valid_from_steps( + flake_dir=flake_dir, config_file=config_file, working_dir=project_root_path, + ) + if valid_from_steps_error: + console.print("[red]✗[/red] Failed to load LibreLane 3 step list for this macro") + console.print(f"[yellow]Error:[/yellow] {valid_from_steps_error}") + sys.exit(1) + + if list_from_steps: + console.print(f"[bold cyan]Valid --from steps for {macro}:[/bold cyan]") + if valid_from_steps: + for step_name in valid_from_steps: + console.print(f" • {step_name}") + else: + console.print("[yellow]No steps found for this flow[/yellow]") + return + + if from_step and from_step not in (valid_from_steps or []): + console.print(f"[red]✗[/red] Invalid --from step: {from_step}") + matches = difflib.get_close_matches(from_step, valid_from_steps or [], n=5, cutoff=0.4) + if matches: + console.print("[yellow]Did you mean:[/yellow]") + for m in matches: + console.print(f" • {m}") + else: + console.print(f"[yellow]Use 'cf harden {macro} --list-from-steps' to see valid step names[/yellow]") + sys.exit(1) + + auto_selected_latest_tag = False + if (from_step or gui_mode_count) and not tag: + runs_dir = macro_dir / 'runs' + candidate_runs = [p for p in runs_dir.iterdir() if p.is_dir()] if runs_dir.exists() else [] + if not candidate_runs: + console.print("[red]✗[/red] No existing runs found for this macro") + if gui_mode_count: + console.print("[yellow]Create a hardening run first, or specify --tag [/yellow]") + else: + console.print("[yellow]Run without --from first, or specify --tag [/yellow]") + sys.exit(1) + latest_run = max(candidate_runs, key=lambda p: p.stat().st_mtime) + tag = latest_run.name + auto_selected_latest_tag = True + elif not tag: + tag = librelane_run.new_run_tag() + + console.print("\n" + "="*60) + console.print(f"[bold cyan]Hardening: {macro}[/bold cyan] [dim](LibreLane 3, native PDN-to-pad routing)[/dim]") + console.print(f"Config: [yellow]{config_file.name}[/yellow]") + console.print(f"Run tag: [yellow]{tag}[/yellow]") + if auto_selected_latest_tag: + if gui_mode_count: + console.print("[yellow]Using latest existing run tag (auto-selected because GUI mode was requested without --tag)[/yellow]") + else: + console.print("[yellow]Using latest existing run tag (auto-selected because --from was provided without --tag)[/yellow]") + if from_step: + console.print(f"Start from: [yellow]{from_step}[/yellow]") + console.print("[yellow]Mode:[/yellow] resume from existing state under this tag (no overwrite)") + if open_in_openroad: + console.print("[yellow]Mode:[/yellow] open existing run in OpenROAD GUI") + elif open_in_klayout: + console.print("[yellow]Mode:[/yellow] open existing run in KLayout GUI") + console.print(f"PDK: [yellow]{pdk}[/yellow]") + console.print(f"PDK Root: [yellow]{pdk_root}[/yellow]") + console.print("Execution: [yellow]Nix (project flake, LibreLane 3)[/yellow]") + console.print("="*60 + "\n") + + cmd, env = librelane_run.build_double_wide_librelane_command( + project_root=project_root_path, + flake_dir=flake_dir, + config_file=config_file, + pdk_root=pdk_root, + pdk=pdk, + tag=tag, + from_step=from_step, + open_in_openroad=open_in_openroad, + open_in_klayout=open_in_klayout, + ) + + if dry_run: + console.print("[yellow]Dry run - command that would be executed:[/yellow]") + console.print(" ".join(cmd)) + return + + console.print(f"[cyan]Running LibreLane 3 via Nix on {macro}...[/cyan]") + + process = None + try: + process = subprocess.Popen( + cmd, + cwd=str(project_root_path), + env=env, + preexec_fn=os.setsid if os.name != 'nt' else None, + ) + returncode = process.wait() + + if returncode == 0: + console.print(f"\n[green]✓[/green] [bold green]Successfully hardened {macro}![/bold green]") + console.print(f"[dim]Run directory: {macro_dir}/runs/{tag}/[/dim]") + elif returncode == -2 or returncode == 130: # SIGINT + console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user") + sys.exit(130) + else: + console.print(f"\n[red]✗[/red] [bold red]Hardening failed with exit code {returncode}[/bold red]") + console.print(f"[yellow]Check logs in: {macro_dir}/runs/{tag}/[/yellow]") + sys.exit(returncode) + + except KeyboardInterrupt: + console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user") + try: + if process is not None: + if os.name != 'nt': + os.killpg(os.getpgid(process.pid), signal.SIGTERM) + process.wait(timeout=5) + else: + process.terminate() + process.wait(timeout=5) + except Exception: + if process is not None: + if os.name != 'nt': + os.killpg(os.getpgid(process.pid), signal.SIGKILL) + else: + process.kill() + sys.exit(130) + except Exception as e: + console.print(f"\n[red]✗[/red] Error: {e}") + sys.exit(1) + + @main.command( 'harden', cls=CategorizedCommand, @@ -4002,6 +4198,11 @@ def harden( ): """Harden a macro using LibreLane (OpenLane 2). + For `double_wide` projects, hardens the single flat wrapper design with + LibreLane 3 instead (using the project's own pinned flake.nix), since its + native PDN generator routes directly to the padframe-aligned power pins. + Every other project type is unaffected and keeps using OpenLane 2. + Examples: cf harden user_proj_example cf harden --list @@ -4059,7 +4260,7 @@ def harden( wait_timeout=wait_timeout, label="Remote PNR", ) - return + sys.exit(1) # Check if project is initialized (skip check for --list or when no macro specified, allow graceful return) if not list_designs and macro: @@ -4074,24 +4275,26 @@ def harden( if not openlane_dir.exists(): console.print(f"[red]✗[/red] OpenLane directory not found: {openlane_dir}") console.print("[yellow]Run 'cf setup' first to install OpenLane[/yellow]") - return + if list_designs: + return + sys.exit(1) if list_from_steps and not macro: console.print("[red]✗[/red] --list-from-steps requires a macro name") console.print("[yellow]Example:[/yellow] cf harden user_proj_example --list-from-steps") - return + sys.exit(1) gui_mode_count = int(open_in_openroad) + int(open_in_klayout) if gui_mode_count > 1: console.print("[red]✗[/red] Use only one GUI flag: --open-in-openroad or --open-in-klayout") - return + sys.exit(1) if gui_mode_count and from_step: console.print("[red]✗[/red] --from cannot be combined with GUI modes") console.print("[yellow]Use --tag to select which run to open, or omit --tag to use latest run[/yellow]") - return + sys.exit(1) if gui_mode_count and list_from_steps: console.print("[red]✗[/red] --list-from-steps cannot be combined with GUI modes") - return + sys.exit(1) # If no macro specified, show prompt with available macros no_macro_specified = not macro and not list_designs and not list_from_steps @@ -4123,7 +4326,7 @@ def harden( if not macro_dir.exists(): console.print(f"[red]✗[/red] Macro not found: {macro}") console.print(f"[yellow]Run 'cf harden --list' to see available macros[/yellow]") - return + sys.exit(1) # Find config file config_file = None @@ -4136,8 +4339,29 @@ def harden( if not config_file: console.print(f"[red]✗[/red] No config file found for {macro}") console.print(f"[yellow]Expected one of: config.json, config.yaml, config.tcl[/yellow]") + sys.exit(1) + + # `double_wide` is hardened as one flat design with LibreLane 3's native + # PDN-to-pad routing, using the project's own pinned flake.nix instead of + # cf-cli's OpenLane-2 Nix/Docker install. Every other project type falls + # through to the existing OpenLane 2 (CI2511) flow below, unaffected. + if read_project_type(project_root_path) == "double_wide": + _harden_double_wide( + macro=macro, + macro_dir=macro_dir, + config_file=Path(config_file), + project_root_path=project_root_path, + tag=tag, + from_step=from_step, + open_in_openroad=open_in_openroad, + open_in_klayout=open_in_klayout, + pdk=pdk, + use_docker=use_docker, + dry_run=dry_run, + list_from_steps=list_from_steps, + ) return - + # Check for LibreLane venv librelane_venv = openlane_dir / '.venv' if not librelane_venv.exists(): @@ -4178,7 +4402,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): " continue\n" " seen.add(step_id)\n" " steps.append(step_id)\n" - "print(json.dumps({'steps': steps}))\n" + f"print({_STEPS_MARKER!r} + json.dumps({{'steps': steps}}))\n" ) result = subprocess.run( [str(librelane_python), '-c', script, macro_config], @@ -4190,8 +4414,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): err = (result.stderr or result.stdout or 'unknown error').strip() return None, err try: - payload = json.loads(result.stdout.strip()) - return payload.get('steps', []), None + return _parse_librelane_steps(result.stdout), None except Exception as exc: return None, str(exc) @@ -4206,7 +4429,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): console.print("[red]✗[/red] Failed to load LibreLane step list for this macro") console.print(f"[yellow]Error:[/yellow] {valid_from_steps_error}") console.print("[yellow]Run 'cf setup --only-openlane' to ensure LibreLane is installed correctly[/yellow]") - return + sys.exit(1) if list_from_steps: console.print(f"[bold cyan]Valid --from steps for {macro}:[/bold cyan]") @@ -4226,7 +4449,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): console.print(f" • {m}") else: console.print(f"[yellow]Use 'cf harden {macro} --list-from-steps' to see valid step names[/yellow]") - return + sys.exit(1) # Fetch versions from upstream console.print("[dim]Fetching version information from cf-cli repository...[/dim]") @@ -4250,7 +4473,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): # Check for conflicting flags if force_nix_flag and force_docker_flag: console.print("[red]✗[/red] Cannot use both --use-nix and --use-docker") - return + sys.exit(1) # Check if Nix is available if force_nix_flag or not force_docker_flag: @@ -4321,7 +4544,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): if not pdk_dir.exists(): console.print(f"[red]✗[/red] PDK not found: {pdk_dir}") console.print("[yellow]Run 'cf setup --only-pdk' to install the PDK[/yellow]") - return + sys.exit(1) auto_selected_latest_tag = False if (from_step or gui_mode_count) and not tag: @@ -4332,7 +4555,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): console.print("[yellow]Create a hardening run first, or specify --tag [/yellow]") else: console.print("[yellow]Run without --from first, or specify --tag [/yellow]") - return + sys.exit(1) candidate_runs = [p for p in runs_dir.iterdir() if p.is_dir()] if not candidate_runs: console.print("[red]✗[/red] No existing runs found for this macro") @@ -4340,7 +4563,7 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): console.print("[yellow]Create a hardening run first, or specify --tag [/yellow]") else: console.print("[yellow]Run without --from first, or specify --tag [/yellow]") - return + sys.exit(1) latest_run = max(candidate_runs, key=lambda p: p.stat().st_mtime) tag = latest_run.name auto_selected_latest_tag = True @@ -4471,13 +4694,13 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): if returncode == 0: console.print(f"\n[green]✓[/green] [bold green]Successfully hardened {macro}![/bold green]") - console.print(f"[dim]Results saved to: {project_root_path}/runs/{macro}/{tag}/[/dim]") + console.print(f"[dim]Run directory: {macro_dir}/runs/{tag}/[/dim]") elif returncode == -2 or returncode == 130: # SIGINT console.print("\n[yellow]⚠[/yellow] Hardening interrupted by user") sys.exit(130) else: console.print(f"\n[red]✗[/red] [bold red]Hardening failed with exit code {returncode}[/bold red]") - console.print(f"[yellow]Check logs in: {project_root_path}/runs/{macro}/{tag}/[/yellow]") + console.print(f"[yellow]Check logs in: {macro_dir}/runs/{tag}/[/yellow]") sys.exit(returncode) except KeyboardInterrupt: @@ -4495,8 +4718,10 @@ def get_valid_from_steps(librelane_python, macro_config, working_dir): os.killpg(os.getpgid(process.pid), signal.SIGKILL) else: process.kill() + sys.exit(130) except Exception as e: console.print(f"\n[red]✗[/red] Error: {e}") + sys.exit(1) @main.group('repo') def repo_group(): @@ -4860,7 +5085,7 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, project_data = json.load(f) project_type = project_data.get('project', {}).get('type', 'digital') - if project_type != 'openframe': + if not is_padframe_only(project_type): gpio_config = get_gpio_config_from_project_json(str(project_json_path)) if not gpio_config or len(gpio_config) == 0: console.print("[red]✗[/red] GPIO configuration not found in project.json") @@ -4882,20 +5107,31 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, if not (pdk_root / pdk).exists(): console.print(f"[red]✗[/red] PDK not found at {pdk_root / pdk}") console.print("[yellow]Run 'cf setup --only-pdk' to install[/yellow]") - return + sys.exit(1) if shutil.which('docker') is None: console.print("[red]✗[/red] Docker not found. Docker is required to run precheck.") - return + sys.exit(1) pdk_path = pdk_root / pdk - + + # The precheck image ships caravel at /opt/caravel. Other harnesses are + # installed by `cf setup` into /caravel, which is mounted below. + golden_root = '/opt/caravel' + if project_type in HARNESS_REPOS: + harness_dir = project_root_path / 'caravel' + if not harness_dir.is_dir(): + console.print(f"[red]✗[/red] {project_type} harness not found at {harness_dir}") + console.print("[yellow]Run 'cf setup --only-caravel' to install it[/yellow]") + sys.exit(1) + golden_root = str(harness_dir.resolve()) + docker_image = 'chipfoundry/mpw_precheck:latest' precheck_args = [ '-i', str(project_root_path), '-p', str(pdk_path), - '-c', '/opt/caravel', + '-c', golden_root, ] if magic_drc: @@ -4909,13 +5145,33 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, if skip_checks: precheck_args.extend(['--skip-checks'] + list(skip_checks)) - inner_cmd = 'pip3 install --upgrade -q --root-user-action=ignore cf-precheck 2>/dev/null && exec cf-precheck ' + ' '.join(precheck_args) + # CF_PRECHECK_SOURCE points at a local cf-precheck checkout to test + # unreleased changes; otherwise the latest release is installed from PyPI. + precheck_source = os.environ.get('CF_PRECHECK_SOURCE') + if precheck_source: + precheck_source = str(Path(precheck_source).expanduser().resolve()) + if not (Path(precheck_source) / 'pyproject.toml').is_file(): + console.print(f"[red]✗[/red] CF_PRECHECK_SOURCE is not a cf-precheck checkout: {precheck_source}") + sys.exit(1) + install_cmd = ( + f'cp -r {shlex.quote(precheck_source)} /tmp/cf-precheck-src && ' + 'pip3 install -q --root-user-action=ignore /tmp/cf-precheck-src' + ) + else: + install_cmd = 'pip3 install --upgrade -q --root-user-action=ignore cf-precheck 2>/dev/null' + inner_cmd = install_cmd + ' && exec cf-precheck ' + ' '.join(precheck_args) docker_cmd = [ 'docker', 'run', '--rm', '--init', '--platform', 'linux/amd64', '-v', f'{project_root_path}:{project_root_path}', '-v', f'{pdk_root}:{pdk_root}', + ] + if golden_root != '/opt/caravel' and not golden_root.startswith(f'{project_root_path}/'): + docker_cmd += ['-v', f'{golden_root}:{golden_root}'] + if precheck_source: + docker_cmd += ['-v', f'{precheck_source}:{precheck_source}:ro'] + docker_cmd += [ '-e', f'PDK_ROOT={pdk_root}', '-e', f'PDK_PATH={pdk_path}', '-e', f'PDKPATH={pdk_path}', @@ -4928,6 +5184,8 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, console.print("[bold cyan]CF Precheck[/bold cyan]") console.print(f"Project: [yellow]{project_root_path}[/yellow]") console.print(f"PDK: [yellow]{pdk}[/yellow]") + if precheck_source: + console.print(f"cf-precheck: [yellow]{precheck_source}[/yellow] (CF_PRECHECK_SOURCE)") if skip_checks: console.print(f"Skipping: [yellow]{', '.join(skip_checks)}[/yellow]") if magic_drc: @@ -4957,7 +5215,7 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, ) if result.returncode != 0: console.print(f"[red]✗[/red] Docker image '{docker_image}' not found. Run 'cf setup --only-precheck' or check your connection.") - return + sys.exit(1) console.print("[yellow]⚠[/yellow] Could not check for image updates (using cached image)") console.print("[cyan]Running cf-precheck...[/cyan]\n") @@ -4990,6 +5248,35 @@ def precheck(project_root, skip_checks, magic_drc, checks, list_checks, dry_run, sys.exit(130) except Exception as e: console.print(f"\n[red]✗[/red] Error running precheck: {e}") + sys.exit(1) + +_STEPS_MARKER = 'CF_STEPS_JSON=' + + +def _parse_librelane_steps(stdout: str): + """Extract the step list printed by the `cf harden` step-list probe.""" + # LibreLane's console may emit terminal control sequences around the + # payload (e.g. a show-cursor escape at exit), so locate it by marker. + for line in stdout.splitlines(): + idx = line.find(_STEPS_MARKER) + if idx != -1: + payload, _ = json.JSONDecoder().raw_decode(line[idx + len(_STEPS_MARKER):]) + return payload.get('steps', []) + raise ValueError('step list not found in LibreLane output') + + +def _cocotb_run_results(run_dir: Path): + """Return (passed, failed) test directory names for a caravel_cocotb run.""" + passed, failed = [], [] + if not run_dir.is_dir(): + return passed, failed + for test_dir in sorted(p for p in run_dir.iterdir() if p.is_dir()): + if (test_dir / 'failed').exists(): + failed.append(test_dir.name) + elif (test_dir / 'passed').exists(): + passed.append(test_dir.name) + return passed, failed + @main.command('verify') @click.argument('test', required=False) @@ -5090,14 +5377,16 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p project_json_path = project_root_path / '.cf' / 'project.json' - # Get project type (needed for openframe flag) with open(project_json_path, 'r') as f: project_data = json.load(f) project_type = project_data.get('project', {}).get('type', 'digital') - - # Check if GPIO configuration exists (skip check if just listing tests or openframe) + # Bare-padframe projects run caravel_cocotb in OpenFrame mode against the + # harness in /caravel (its toplevel_cocotb.v sets the pad count). + padframe_only = is_padframe_only(project_type) + + # Check if GPIO configuration exists (skip check if just listing tests or padframe-only) if not list_tests: - if project_type != 'openframe': + if not padframe_only: gpio_config = get_gpio_config_from_project_json(str(project_json_path)) if not gpio_config or len(gpio_config) == 0: console.print("[red]✗[/red] GPIO configuration not found in project.json") @@ -5197,7 +5486,7 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p if dry_run: console.print("[bold yellow]Dry run - configuration ready[/bold yellow]\n") - openframe_flag = " --openframe" if project_type == 'openframe' else "" + openframe_flag = " --openframe" if padframe_only else "" if test: console.print(f"Would run: {caravel_cocotb_bin} -t {test} -sim {sim_arg}{openframe_flag}") elif run_all: @@ -5257,12 +5546,15 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p if sim.lower() == 'gl': cmd.extend(['-sim', 'GL']) - # Add openframe flag for openframe projects - if project_type == 'openframe': + if padframe_only: cmd.append('--openframe') # Add CI flag to disable Docker interactive mode (required when not running in a terminal) cmd.append('--CI') + + from datetime import datetime + run_tag = f"cf_verify_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}" + cmd.extend(['-tag', run_tag]) # Run cocotb tests console.print(f"[cyan]Running cocotb verification...[/cyan]") @@ -5278,9 +5570,20 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p # Wait for process to complete returncode = process.wait() - + if returncode == 0: - console.print(f"\n[green]✓[/green] Verification passed!") + # caravel_cocotb exits 0 even when tests fail; it records the + # outcome as a 'passed' / 'failed' marker file in each test dir. + passed, failed = _cocotb_run_results(cocotb_dir / 'sim' / run_tag) + if failed or not passed: + console.print( + f"\n[red]✗[/red] Verification failed: {len(failed)} failed, {len(passed)} passed" + ) + for name in failed: + console.print(f" [red]•[/red] {name}") + console.print(f"[yellow]Check logs in: {cocotb_dir / 'sim' / run_tag}[/yellow]") + sys.exit(1) + console.print(f"\n[green]✓[/green] Verification passed! ({len(passed)} test(s))") elif returncode == -2 or returncode == 130: # SIGINT console.print("\n[yellow]⚠[/yellow] Verification interrupted by user") sys.exit(130) @@ -5304,8 +5607,10 @@ def verify(test, project_root, sim, list_tests, run_all, tag, dry_run, remote, p os.killpg(os.getpgid(process.pid), signal.SIGKILL) else: process.kill() + sys.exit(130) except Exception as e: console.print(f"\n[red]✗[/red] Error: {e}") + sys.exit(1) DEFAULT_API_URL = 'https://api.chipfoundry.io' diff --git a/chipfoundry_cli/remote_precheck_git.py b/chipfoundry_cli/remote_precheck_git.py index bd9cf7b..b1cd58b 100644 --- a/chipfoundry_cli/remote_precheck_git.py +++ b/chipfoundry_cli/remote_precheck_git.py @@ -22,8 +22,10 @@ class RemotePrecheckGitError(Exception): ("analog", "gds/user_analog_project_wrapper"), ("digital", "gds/user_project_wrapper"), ("openframe", "gds/openframe_project_wrapper"), + ("double_wide", "gds/double_wide_openframe_project_wrapper"), ("mini", "gds/user_project_wrapper_mini4"), ) +_PADFRAME_ONLY_KINDS = frozenset({"openframe", "double_wide"}) _GDS_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz") USER_DEFINES_REL = "verilog/rtl/user_defines.v" @@ -301,7 +303,8 @@ def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]: f"{e} " "Check that the wrapper GDS is committed and located under the " "expected path (e.g. gds/user_project_wrapper.gds, " - "gds/openframe_project_wrapper.gds, etc.). If you use Git LFS, " + "gds/openframe_project_wrapper.gds, " + "gds/double_wide_openframe_project_wrapper.gds, etc.). If you use Git LFS, " "run `git lfs pull` so the actual file (not the pointer) is present." ) from e out: Set[str] = {gds_rel} @@ -313,8 +316,8 @@ def _push_critical_paths(repo: Path, project_json: Path) -> Set[str]: "Fix project type or GDS layout before remote push." ) - # user_defines.v is required except for openframe, matching collect_project_files(). - if kind_gds != "openframe": + # user_defines.v is required except for bare-padframe projects, matching collect_project_files(). + if kind_gds not in _PADFRAME_ONLY_KINDS: ud = repo / USER_DEFINES_REL if ud.is_file() or _path_tracked_in_git(repo, USER_DEFINES_REL): out.add(USER_DEFINES_REL) diff --git a/chipfoundry_cli/utils.py b/chipfoundry_cli/utils.py index 4def908..8eb77a9 100644 --- a/chipfoundry_cli/utils.py +++ b/chipfoundry_cli/utils.py @@ -24,8 +24,34 @@ 'user_analog_project_wrapper.gds.gz': 'analog', 'openframe_project_wrapper.gds': 'openframe', 'openframe_project_wrapper.gds.gz': 'openframe', + 'double_wide_openframe_project_wrapper.gds': 'double_wide', + 'double_wide_openframe_project_wrapper.gds.gz': 'double_wide', } +PROJECT_TYPES: Tuple[str, ...] = ("digital", "analog", "openframe", "double_wide") + +# Bare-padframe project types: no management SoC, no user_defines.v and no +# GPIO configuration. The user wrapper drives every pad control signal. +PADFRAME_ONLY_TYPES = frozenset({"openframe", "double_wide"}) + +# Project types whose harness is not caravel / caravel-lite. `cf setup` clones +# the harness into /caravel so CARAVEL_ROOT keeps working for cocotb +# and precheck. +HARNESS_REPOS: Dict[str, str] = { + "double_wide": "https://github.com/chipfoundry/double_wide_openframe", +} +DEFAULT_HARNESS_REF = "main" + +# `double_wide` is hardened as one flat design (no separate macros), and its +# upstream template ships its own flake.nix pinning LibreLane 3: the native +# PDN generator reaches the padframe-aligned power pins directly, so no +# bridging "connection" macro is needed. Every other project type keeps using +# `openlane_version` (LibreLane/OpenLane 2, fetched from cf-cli's versions.json). +# Pin the exact commit (not the `3.1.0.dev2` tag) so `nix` resolves it from the +# local store/cache without an unauthenticated GitHub API call that can 403. +DOUBLE_WIDE_LIBRELANE_REPO = "github:librelane/librelane" +DOUBLE_WIDE_LIBRELANE_REV = "f18a07aabb2dd3d6a7e3acc8c3b3621baa784a08" # 3.1.0.dev2 + # Canonical GDS wrapper layouts used by remote precheck and remote push. # Each entry is (project_kind, base path without suffix). Suffixes below. # Keep in sync with chipignite-backend-services/src/precheck_service and @@ -34,9 +60,35 @@ ("analog", "gds/user_analog_project_wrapper"), ("digital", "gds/user_project_wrapper"), ("openframe", "gds/openframe_project_wrapper"), + ("double_wide", "gds/double_wide_openframe_project_wrapper"), ) GDS_WRAPPER_SUFFIXES: Tuple[str, ...] = (".gds", ".gds.gz") + +def is_padframe_only(project_type: Optional[str]) -> bool: + """True for project types that have no management SoC or user_defines.v.""" + return project_type in PADFRAME_ONLY_TYPES + + +def read_project_type(project_root: Path, default: str = "digital") -> str: + """Read `project.type` from `.cf/project.json`, defaulting if missing/unreadable.""" + project_json_path = Path(project_root) / ".cf" / "project.json" + if project_json_path.is_file(): + try: + data = json.loads(project_json_path.read_text()) + project_type = data.get("project", {}).get("type") + if isinstance(project_type, str) and project_type.strip(): + return project_type.strip() + except (json.JSONDecodeError, IOError): + pass + return default + + +def harness_ref(project_type: str, versions: Optional[dict] = None) -> str: + """Git ref of the harness that `cf setup` installs for ``project_type``.""" + refs = (versions or {}).get("harness_refs") or {} + return refs.get(project_type) or DEFAULT_HARNESS_REF + USER_DEFINES_REL = "verilog/rtl/user_defines.v" CF_PROJECT_JSON_REL = ".cf/project.json" @@ -89,7 +141,7 @@ def collect_project_files(project_root: str) -> Dict[str, Optional[str]]: Collect required project files from the given project_root. Returns a dict mapping logical names to absolute file paths (or None if not found and optional). Raises FileNotFoundError if any required file is missing. - Openframe projects do not require user_defines.v. + Openframe and double-wide projects do not require user_defines.v. """ project_root = Path(project_root) collected = {} @@ -158,13 +210,12 @@ def collect_project_files(project_root: str) -> Dict[str, Optional[str]]: gds_name, gds_path = gds_file_to_use collected[f"gds/{gds_name}"] = gds_path - # Collect standard required files (user_defines.v not required for openframe) cf_json_path = project_root / ".cf" / "project.json" collected[".cf/project.json"] = str(cf_json_path) if cf_json_path.exists() else None user_defines_path = project_root / "verilog" / "rtl" / "user_defines.v" - if project_type == "openframe": - collected["verilog/rtl/user_defines.v"] = None # openframe does not use user_defines.v + if is_padframe_only(project_type): + collected["verilog/rtl/user_defines.v"] = None else: if user_defines_path.exists(): collected["verilog/rtl/user_defines.v"] = str(user_defines_path) diff --git a/tests/test_double_wide.py b/tests/test_double_wide.py new file mode 100644 index 0000000..549c7ed --- /dev/null +++ b/tests/test_double_wide.py @@ -0,0 +1,341 @@ +""" +Tests for double-wide openframe (``double_wide``) project support. +""" +import json +import os +import stat +from pathlib import Path + +import pytest +from click.testing import CliRunner + +import chipfoundry_cli.main as cf_main +from chipfoundry_cli.main import main, _collect_push_candidates +from chipfoundry_cli.remote_precheck_git import _detect_wrapper_gds +from chipfoundry_cli.utils import ( + GDS_TYPE_MAP, + HARNESS_REPOS, + PROJECT_TYPES, + collect_project_files, + harness_ref, + is_padframe_only, +) + +WRAPPER = "double_wide_openframe_project_wrapper" + +FAKE_VERSIONS = { + "mpw_tags": {"sky130A": "2026.2.9", "sky130B": "2026.2.9"}, + "openlane_version": "CI2511", + "open_pdks_commits": {"sky130A": "3e0e31dc", "sky130B": "3e0e31dc"}, +} + + +def _write_project_json(root: Path, project_type: str = "double_wide") -> Path: + cf_dir = root / ".cf" + cf_dir.mkdir(parents=True, exist_ok=True) + path = cf_dir / "project.json" + path.write_text(json.dumps({"project": {"name": "dw", "type": project_type}})) + return path + + +@pytest.fixture +def dw_project(tmp_path: Path) -> Path: + """Initialized double-wide project with PDK, harness and wrapper GDS.""" + _write_project_json(tmp_path) + (tmp_path / "gds").mkdir() + (tmp_path / "gds" / f"{WRAPPER}.gds").write_bytes(b"gds") + (tmp_path / "dependencies" / "pdks" / "sky130A").mkdir(parents=True) + (tmp_path / "caravel" / "gds").mkdir(parents=True) + return tmp_path + + +class TestUtils: + def test_project_type_registered(self): + assert "double_wide" in PROJECT_TYPES + assert GDS_TYPE_MAP[f"{WRAPPER}.gds"] == "double_wide" + assert GDS_TYPE_MAP[f"{WRAPPER}.gds.gz"] == "double_wide" + + def test_padframe_only(self): + assert is_padframe_only("double_wide") + assert is_padframe_only("openframe") + assert not is_padframe_only("digital") + assert not is_padframe_only("analog") + assert not is_padframe_only(None) + + def test_harness_ref_defaults_to_main(self): + assert "double_wide" in HARNESS_REPOS + assert harness_ref("double_wide") == "main" + assert harness_ref("double_wide", {"mpw_tags": {}}) == "main" + + def test_harness_ref_from_versions(self): + versions = {"harness_refs": {"double_wide": "DW2026.1"}} + assert harness_ref("double_wide", versions) == "DW2026.1" + + def test_collect_without_user_defines(self, tmp_path: Path): + (tmp_path / "gds").mkdir() + (tmp_path / "gds" / f"{WRAPPER}.gds.gz").write_bytes(b"gz") + collected = collect_project_files(str(tmp_path)) + assert f"gds/{WRAPPER}.gds.gz" in collected + assert collected["verilog/rtl/user_defines.v"] is None + + def test_collect_rejects_mixed_types(self, tmp_path: Path): + (tmp_path / "gds").mkdir() + (tmp_path / "gds" / f"{WRAPPER}.gds").write_bytes(b"gds") + (tmp_path / "gds" / "openframe_project_wrapper.gds").write_bytes(b"gds") + with pytest.raises(FileNotFoundError, match="Multiple project types"): + collect_project_files(str(tmp_path)) + + +class TestPushAndRemoteDetection: + def test_push_candidates_skip_user_defines(self, tmp_path: Path): + (tmp_path / "gds").mkdir() + (tmp_path / "gds" / f"{WRAPPER}.gds").write_bytes(b"gds") + (tmp_path / "verilog" / "rtl").mkdir(parents=True) + (tmp_path / "verilog" / "rtl" / "user_defines.v").write_text("// unused") + candidates = _collect_push_candidates(tmp_path) + assert [c[0] for c in candidates] == [f"gds/{WRAPPER}.gds"] + + def test_remote_detects_double_wide_gds(self, tmp_path: Path): + (tmp_path / "gds").mkdir() + (tmp_path / "gds" / f"{WRAPPER}.gds").write_bytes(b"gds") + assert _detect_wrapper_gds(tmp_path) == ("double_wide", f"gds/{WRAPPER}.gds") + + +class TestGpioConfig: + def test_gpio_config_not_available(self, tmp_path: Path): + _write_project_json(tmp_path) + result = CliRunner().invoke(main, ["gpio-config", "--project-root", str(tmp_path)]) + assert result.exit_code != 0 + assert "not available for double_wide projects" in result.output + + +class TestSetup: + def test_setup_installs_harness_not_mcw(self, tmp_path: Path, monkeypatch): + _write_project_json(tmp_path) + monkeypatch.setattr(cf_main, "fetch_versions_from_upstream", lambda *a, **k: FAKE_VERSIONS) + result = CliRunner().invoke( + main, ["setup", "--project-root", str(tmp_path), "--dry-run"] + ) + assert result.exit_code == 0, result.output + assert "chipfoundry/double_wide_openframe" in result.output + assert "MCW not needed for double_wide projects" in result.output + assert "Management Core Wrapper" not in result.output + assert "caravel-lite" not in result.output + + def test_setup_uses_pinned_harness_ref(self, tmp_path: Path, monkeypatch): + _write_project_json(tmp_path) + versions = dict(FAKE_VERSIONS, harness_refs={"double_wide": "DW2026.1"}) + monkeypatch.setattr(cf_main, "fetch_versions_from_upstream", lambda *a, **k: versions) + result = CliRunner().invoke( + main, ["setup", "--project-root", str(tmp_path), "--only-caravel", "--dry-run"] + ) + assert result.exit_code == 0, result.output + assert "tag: DW2026.1" in result.output.replace("\n", "") + + + def test_setup_fails_when_a_step_fails(self, tmp_path: Path, monkeypatch): + import subprocess + + _write_project_json(tmp_path) + monkeypatch.setattr(cf_main, "fetch_versions_from_upstream", lambda *a, **k: FAKE_VERSIONS) + + def failing_run(cmd, *args, **kwargs): + raise subprocess.CalledProcessError(128, cmd, stderr="clone failed") + + monkeypatch.setattr(cf_main.subprocess, "run", failing_run) + result = CliRunner().invoke( + main, ["setup", "--project-root", str(tmp_path), "--only-timing"] + ) + assert result.exit_code == 1, result.output + assert "Setup completed with errors" in result.output + + +class TestPrecheck: + def test_precheck_uses_harness_as_golden_root(self, dw_project: Path, monkeypatch): + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 0, result.output + out = result.output.replace("\n", "") + assert f"-c {(dw_project / 'caravel').resolve()}" in out + assert "/opt/caravel" not in out + assert "GPIO configuration not found" not in out + + def test_precheck_mounts_symlinked_harness(self, dw_project: Path, tmp_path_factory, monkeypatch): + external = tmp_path_factory.mktemp("harness") + (dw_project / "caravel" / "gds").rmdir() + (dw_project / "caravel").rmdir() + os.symlink(external, dw_project / "caravel") + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 0, result.output + out = result.output.replace("\n", "") + assert f"-v {external.resolve()}:{external.resolve()}" in out + + def test_precheck_requires_harness(self, dw_project: Path, monkeypatch): + (dw_project / "caravel" / "gds").rmdir() + (dw_project / "caravel").rmdir() + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 1 + assert "harness not found" in result.output + assert "cf setup --only-caravel" in result.output + + def test_precheck_local_source(self, dw_project: Path, tmp_path_factory, monkeypatch): + src = tmp_path_factory.mktemp("cf-precheck") + (src / "pyproject.toml").write_text("[project]\nname = 'cf-precheck'\n") + monkeypatch.setenv("CF_PRECHECK_SOURCE", str(src)) + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 0, result.output + out = result.output.replace("\n", "") + assert f"-v {src.resolve()}:{src.resolve()}:ro" in out + assert f"pip3 install -q --root-user-action=ignore /tmp/cf-precheck-src" in out + assert "--upgrade -q --root-user-action=ignore cf-precheck" not in out + + def test_precheck_rejects_bad_local_source(self, dw_project: Path, tmp_path, monkeypatch): + monkeypatch.setenv("CF_PRECHECK_SOURCE", str(tmp_path / "missing")) + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 1 + assert "not a cf-precheck checkout" in result.output + + def test_precheck_fails_without_pdk(self, dw_project: Path, monkeypatch): + (dw_project / "dependencies" / "pdks" / "sky130A").rmdir() + monkeypatch.setattr(cf_main.shutil, "which", lambda name: f"/usr/bin/{name}") + result = CliRunner().invoke( + main, ["precheck", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 1 + assert "PDK not found" in result.output + + +class TestHarden: + def test_harden_unknown_macro_fails(self, dw_project: Path): + (dw_project / "openlane").mkdir() + result = CliRunner().invoke( + main, ["harden", "no_such_macro", "--project-root", str(dw_project)] + ) + assert result.exit_code == 1 + assert "Macro not found" in result.output + + def test_harden_missing_config_fails(self, dw_project: Path): + (dw_project / "openlane" / "user_proj_counter").mkdir(parents=True) + result = CliRunner().invoke( + main, ["harden", "user_proj_counter", "--project-root", str(dw_project)] + ) + assert result.exit_code == 1 + assert "No config file found" in result.output + + def _make_double_wide_macro(self, root: Path, macro: str = WRAPPER) -> Path: + macro_dir = root / "openlane" / macro + macro_dir.mkdir(parents=True, exist_ok=True) + (macro_dir / "config.json").write_text("{}") + (root / "flake.nix").write_text("{ }\n") + return macro_dir + + def test_harden_double_wide_dispatches_to_librelane3(self, dw_project: Path, monkeypatch): + self._make_double_wide_macro(dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_flake_dir", lambda root: dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_nix_available", lambda flake_dir: True) + monkeypatch.setattr( + cf_main.librelane_run, + "get_double_wide_valid_from_steps", + lambda **kwargs: (["Yosys.Synthesis", "OpenROAD.Floorplan"], None), + ) + monkeypatch.setattr( + cf_main.librelane_run, + "build_double_wide_librelane_command", + lambda **kwargs: (["nix", "develop", str(dw_project), "--command", "librelane"], {}), + ) + result = CliRunner().invoke( + main, ["harden", WRAPPER, "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 0, result.output + out = result.output.replace("\n", " ") + assert "LibreLane 3, native PDN-to-pad routing" in out + assert "nix develop" in out + + def test_harden_double_wide_list_from_steps(self, dw_project: Path, monkeypatch): + self._make_double_wide_macro(dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_flake_dir", lambda root: dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_nix_available", lambda flake_dir: True) + monkeypatch.setattr( + cf_main.librelane_run, + "get_double_wide_valid_from_steps", + lambda **kwargs: (["Yosys.Synthesis", "OpenROAD.Floorplan"], None), + ) + result = CliRunner().invoke( + main, ["harden", WRAPPER, "--project-root", str(dw_project), "--list-from-steps"] + ) + assert result.exit_code == 0, result.output + assert "Yosys.Synthesis" in result.output + assert "OpenROAD.Floorplan" in result.output + + def test_harden_double_wide_rejects_use_docker(self, dw_project: Path): + self._make_double_wide_macro(dw_project) + result = CliRunner().invoke( + main, ["harden", WRAPPER, "--project-root", str(dw_project), "--use-docker"] + ) + assert result.exit_code != 0 + assert "does not support --use-docker" in result.output + + def test_harden_double_wide_requires_flake(self, dw_project: Path, monkeypatch): + self._make_double_wide_macro(dw_project) + (dw_project / "flake.nix").unlink() + monkeypatch.setattr(cf_main.librelane_run, "double_wide_flake_dir", lambda root: None) + result = CliRunner().invoke( + main, ["harden", WRAPPER, "--project-root", str(dw_project)] + ) + assert result.exit_code != 0 + assert "No flake.nix found" in result.output + + def test_harden_double_wide_requires_nix(self, dw_project: Path, monkeypatch): + self._make_double_wide_macro(dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_flake_dir", lambda root: dw_project) + monkeypatch.setattr(cf_main.librelane_run, "double_wide_nix_available", lambda flake_dir: False) + result = CliRunner().invoke( + main, ["harden", WRAPPER, "--project-root", str(dw_project)] + ) + assert result.exit_code != 0 + assert "Nix not available" in result.output + + def test_harden_other_project_types_unaffected(self, tmp_path: Path): + """Non-double_wide project types must keep using the OpenLane 2 venv path.""" + _write_project_json(tmp_path, project_type="digital") + macro_dir = tmp_path / "openlane" / "user_proj_example" + macro_dir.mkdir(parents=True) + (macro_dir / "config.json").write_text("{}") + result = CliRunner().invoke( + main, ["harden", "user_proj_example", "--project-root", str(tmp_path)] + ) + assert result.exit_code != 0 + assert "LibreLane not installed" in result.output + assert "LibreLane 3" not in result.output + + +class TestVerify: + def test_verify_runs_in_openframe_mode(self, dw_project: Path): + (dw_project / "verilog" / "dv" / "cocotb" / "counter").mkdir(parents=True) + (dw_project / "verilog" / "dv" / "cocotb" / "counter" / "counter.yaml").write_text("Tests: []\n") + bin_dir = dw_project / "venv-cocotb" / "bin" + bin_dir.mkdir(parents=True) + tool = bin_dir / "caravel_cocotb" + tool.write_text("#!/bin/sh\n") + tool.chmod(tool.stat().st_mode | stat.S_IEXEC) + + result = CliRunner().invoke( + main, ["verify", "counter", "--project-root", str(dw_project), "--dry-run"] + ) + assert result.exit_code == 0, result.output + assert "GPIO configuration not found" not in result.output + assert "-t counter -sim RTL --openframe" in result.output.replace("\n", "") diff --git a/tests/test_harden_steps.py b/tests/test_harden_steps.py new file mode 100644 index 0000000..2c18420 --- /dev/null +++ b/tests/test_harden_steps.py @@ -0,0 +1,25 @@ +"""Tests for parsing the LibreLane step list probed by `cf harden`.""" + +import pytest + +from chipfoundry_cli.main import _parse_librelane_steps + + +def test_plain_payload(): + out = 'CF_STEPS_JSON={"steps": ["Yosys.Synthesis", "OpenROAD.Floorplan"]}\n' + assert _parse_librelane_steps(out) == ["Yosys.Synthesis", "OpenROAD.Floorplan"] + + +def test_terminal_escapes_around_payload(): + # Interactive terminals get a show-cursor escape from LibreLane's console. + out = ( + '\x1b[?25lnoise\n' + '\x1b[2KCF_STEPS_JSON={"steps": ["Yosys.Synthesis"]}\x1b[?25h\n' + '\x1b[?25h' + ) + assert _parse_librelane_steps(out) == ["Yosys.Synthesis"] + + +def test_missing_payload(): + with pytest.raises(ValueError): + _parse_librelane_steps("\x1b[?25h") diff --git a/tests/test_verify_results.py b/tests/test_verify_results.py new file mode 100644 index 0000000..3280e99 --- /dev/null +++ b/tests/test_verify_results.py @@ -0,0 +1,27 @@ +"""Tests for interpreting caravel_cocotb run directories in `cf verify`.""" + +from chipfoundry_cli.main import _cocotb_run_results + + +def _test_dir(run_dir, name, marker=None): + d = run_dir / name + d.mkdir(parents=True) + if marker: + (d / marker).touch() + + +def test_all_passed(tmp_path): + _test_dir(tmp_path, "RTL-compilation") + _test_dir(tmp_path, "RTL-counter", "passed") + _test_dir(tmp_path, "RTL-timer", "passed") + assert _cocotb_run_results(tmp_path) == (["RTL-counter", "RTL-timer"], []) + + +def test_failed_test_is_reported(tmp_path): + _test_dir(tmp_path, "RTL-counter", "passed") + _test_dir(tmp_path, "RTL-timer", "failed") + assert _cocotb_run_results(tmp_path) == (["RTL-counter"], ["RTL-timer"]) + + +def test_missing_run_dir(tmp_path): + assert _cocotb_run_results(tmp_path / "nope") == ([], []) diff --git a/versions.json b/versions.json index 40c86d9..792f83a 100644 --- a/versions.json +++ b/versions.json @@ -7,5 +7,8 @@ "open_pdks_commits": { "sky130A": "3e0e31dcce8519a7dbb82590346db16d91b7244f", "sky130B": "3e0e31dcce8519a7dbb82590346db16d91b7244f" + }, + "harness_refs": { + "double_wide": "main" } }