From c3343dfa1411937ffee912b748c87d1676d4f8ed Mon Sep 17 00:00:00 2001 From: jdicorpo Date: Mon, 14 Sep 2026 14:31:57 -0700 Subject: [PATCH] Add cf preview to scan macros, classify LibreLane strategy, and sync metrics. Local lint/synth (or --remote) posts hierarchy, IP, gates, and area so the portal Design stage can show the inventory. Co-authored-by: Cursor --- chipfoundry_cli/__init__.py | 2 +- chipfoundry_cli/librelane_run.py | 294 ++++++++++ chipfoundry_cli/main.py | 20 +- chipfoundry_cli/preview.py | 920 +++++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/test_preview_command.py | 243 ++++++++ 6 files changed, 1474 insertions(+), 7 deletions(-) create mode 100644 chipfoundry_cli/librelane_run.py create mode 100644 chipfoundry_cli/preview.py create mode 100644 tests/test_preview_command.py diff --git a/chipfoundry_cli/__init__.py b/chipfoundry_cli/__init__.py index 56cdc52..8cf6190 100644 --- a/chipfoundry_cli/__init__.py +++ b/chipfoundry_cli/__init__.py @@ -1,2 +1,2 @@ """ChipFoundry CLI package: Automate project submission to SFTP.""" -__version__ = "2.5.4" \ No newline at end of file +__version__ = "2.6.0" \ No newline at end of file diff --git a/chipfoundry_cli/librelane_run.py b/chipfoundry_cli/librelane_run.py new file mode 100644 index 0000000..1ddf5a4 --- /dev/null +++ b/chipfoundry_cli/librelane_run.py @@ -0,0 +1,294 @@ +"""Shared LibreLane invocation helpers for `cf harden` and `cf preview`.""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import sys +from datetime import datetime +from pathlib import Path +from typing import Dict, List, Optional, Sequence, Tuple + +CONFIG_EXTS = ("json", "yaml", "yml", "tcl") +OPENLANE_DIR_NAMES = ("openlane", "librelane") +SYNTH_TO_STEP = "Yosys.Synthesis" + + +def find_flow_root(project_root: Path) -> Optional[Path]: + """Return the first existing openlane/ or librelane/ directory.""" + for name in OPENLANE_DIR_NAMES: + candidate = project_root / name + if candidate.is_dir(): + return candidate + return None + + +def find_config_file(macro_dir: Path) -> Optional[Path]: + for ext in CONFIG_EXTS: + path = macro_dir / f"config.{ext}" + if path.is_file(): + return path + return None + + +def list_macro_configs(project_root: Path) -> List[Dict[str, str]]: + """Discover LibreLane macros as ``{name, config_path, flow_root}``.""" + found: List[Dict[str, str]] = [] + seen = set() + for dir_name in OPENLANE_DIR_NAMES: + flow_root = project_root / dir_name + if not flow_root.is_dir(): + continue + for child in sorted(flow_root.iterdir()): + if not child.is_dir() or child.name.startswith("."): + continue + config = find_config_file(child) + if config is None: + continue + rel = str(config.relative_to(project_root)) + if child.name in seen: + continue + seen.add(child.name) + found.append( + { + "name": child.name, + "config_path": rel, + "flow_root": dir_name, + } + ) + return found + + +def detect_pdk(project_root: Path, pdk_override: Optional[str] = None) -> str: + if pdk_override: + return pdk_override + project_json = project_root / ".cf" / "project.json" + if project_json.is_file(): + data = json.loads(project_json.read_text()) + pdk = data.get("pdk") + if isinstance(pdk, str) and pdk.strip(): + return pdk.strip() + return "sky130A" + + +def pdk_root_dir(project_root: Path) -> Path: + return project_root / "dependencies" / "pdks" + + +def librelane_venv(flow_root: Path) -> Path: + return flow_root / ".venv" + + +def resolve_execution_backend( + *, + openlane_version: str, + force_nix: bool = False, + force_docker: bool = False, +) -> Tuple[str, Optional[str]]: + """Return ``(\"nix\"|\"docker\", error)``.""" + if force_nix and force_docker: + return "", "Cannot use both --use-nix and --use-docker" + + use_nix = False + if force_nix or not force_docker: + if shutil.which("nix") is not None: + try: + result = subprocess.run( + [ + "nix", + "flake", + "metadata", + f"github:chipfoundry/openlane-2/{openlane_version}", + "--json", + ], + capture_output=True, + timeout=5, + ) + use_nix = result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + use_nix = False + if force_nix and not use_nix: + return "", "Nix not available or cannot access LibreLane flake" + + if not use_nix: + use_docker = False + try: + result = subprocess.run( + ["docker", "info"], + capture_output=True, + timeout=5, + ) + use_docker = result.returncode == 0 + except (OSError, subprocess.TimeoutExpired): + use_docker = False + if force_docker and not use_docker: + return "", "Docker not available" + if use_docker: + return "docker", None + if not force_nix: + return "", "Neither Nix nor Docker is available" + + return "nix", None + + +def build_librelane_command( + *, + project_root: Path, + flow_root: Path, + config_file: Path, + pdk: str, + pdk_root: Path, + tag: str, + openlane_version: str, + backend: str, + from_step: Optional[str] = None, + to_step: Optional[str] = None, + overwrite: bool = True, +) -> Tuple[List[str], Dict[str, str]]: + """Build a Nix or Docker LibreLane command. Does not include GUI flows.""" + env = os.environ.copy() + env.update( + { + "PROJECT_ROOT": str(project_root), + "PDK_ROOT": str(pdk_root), + "PDK": pdk, + "LIBRELANE_RUN_TAG": tag, + } + ) + + if backend == "nix": + cmd = [ + "nix", + "run", + f"github:chipfoundry/openlane-2/{openlane_version}", + "--", + "--manual-pdk", + "--pdk-root", + str(pdk_root), + "--pdk", + pdk, + "--ef-save-views-to", + str(project_root), + "--run-tag", + tag, + ] + else: + venv_bin = librelane_venv(flow_root) / "bin" + env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}" + py_major = sys.version_info.major + py_minor = sys.version_info.minor + env["PYTHONPATH"] = str( + librelane_venv(flow_root) + / "lib" + / f"python{py_major}.{py_minor}" + / "site-packages" + ) + cmd = [ + str(venv_bin / "python3"), + "-m", + "librelane", + "-m", + str(project_root), + "-m", + str(pdk_root), + ] + try: + if not sys.stdin.isatty(): + cmd.append("--docker-no-tty") + except Exception: + cmd.append("--docker-no-tty") + cmd.append("--dockerized") + cmd.extend( + [ + "--manual-pdk", + "--pdk-root", + str(pdk_root), + "--pdk", + pdk, + "--ef-save-views-to", + str(project_root), + "--run-tag", + tag, + ] + ) + + if overwrite and not from_step: + cmd.append("--overwrite") + if from_step: + cmd.extend(["--from", from_step]) + if to_step: + cmd.extend(["--to", to_step]) + cmd.append(str(config_file)) + return cmd, env + + +def new_run_tag() -> str: + return datetime.now().strftime("%y_%m_%d_%H_%M") + + +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 + candidates = [ + run_dir / "Yosys.Synthesis" / "reports" / "stat.json", + run_dir / "05-yosys-synthesis" / "reports" / "stat.json", + run_dir / "final" / "metrics.json", + ] + if run_dir.is_dir(): + for path in sorted(run_dir.rglob("stat.json")): + candidates.append(path) + for path in sorted(run_dir.rglob("metrics.json")): + candidates.append(path) + for path in candidates: + if path.is_file(): + return path + return None + + +def parse_synth_metrics(stat_path: Path) -> Dict[str, object]: + """Parse Yosys stat.json or LibreLane metrics.json into gate/area fields. + + Raises ValueError if the file cannot be parsed or has no cell count. + """ + raw = json.loads(stat_path.read_text()) + if not isinstance(raw, dict): + raise ValueError(f"Synth metrics at {stat_path} must be a JSON object") + + gate_count: Optional[int] = None + area_um2: Optional[float] = None + + design = raw.get("design") + if isinstance(design, dict): + cells = design.get("num_cells") + if cells is not None: + gate_count = int(cells) + area = design.get("area") + if area not in (None, 0, 0.0, "0"): + area_um2 = float(area) + + if gate_count is None and "design__instance__count" in raw: + gate_count = int(float(raw["design__instance__count"])) + if area_um2 is None and raw.get("design__instance__area") not in (None, "", 0, 0.0): + area_um2 = float(raw["design__instance__area"]) + + if gate_count is None: + raise ValueError(f"No cell count in synth metrics at {stat_path}") + + area_mm2 = (area_um2 / 1_000_000.0) if area_um2 is not None else None + return { + "gate_count": gate_count, + "estimated_area_mm2": area_mm2, + "metrics_path": str(stat_path), + } + + +def run_librelane( + cmd: Sequence[str], + *, + cwd: Path, + env: Dict[str, str], +) -> int: + process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env) + return process.wait() diff --git a/chipfoundry_cli/main.py b/chipfoundry_cli/main.py index ff5e2a9..deacfd5 100644 --- a/chipfoundry_cli/main.py +++ b/chipfoundry_cli/main.py @@ -3801,8 +3801,12 @@ def _queue_and_maybe_poll_remote_job( poll: bool, wait_timeout: int, label: str, -) -> None: - """POST a remote platform job and optionally poll until terminal status.""" +): + """POST a remote platform job and optionally poll until terminal status. + + Returns the create-response job dict, or the last GET payload when polling + completes. Returns None for dry-run. + """ import time from urllib.parse import urlencode @@ -3810,7 +3814,7 @@ def _queue_and_maybe_poll_remote_job( if dry_run: console.print(f"[cyan]Would POST[/cyan] {create_path}?" + urlencode(params)) - return + return None if poll and wait_timeout < 0: console.print( "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)." @@ -3858,13 +3862,13 @@ def _queue_and_maybe_poll_remote_job( console.print(f"[green]✓[/green] {label} completed") if job.get("github_pr_url"): console.print(f" Pull request: {job['github_pr_url']}") - return + return job if not poll: console.print( f"[dim]Not waiting: use [bold]--remote --poll[/bold] to stream progress " f"([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]" ) - return + return job deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout if wait_timeout == 0: console.print("[dim]Polling until the job completes (no timeout).[/dim]") @@ -3928,6 +3932,7 @@ def _queue_and_maybe_poll_remote_job( console.print(f"[green]✓[/green] {label} completed") if github_pr_url: console.print(f" Pull request: {github_pr_url}") + return j2 elif terminal == "failed": console.print(f"[red]✗[/red] {label} failed: {fail_message}") raise SystemExit(1) @@ -5782,5 +5787,10 @@ def whoami_cmd(): raise SystemExit(1) +from chipfoundry_cli.preview import register_preview + +register_preview(main) + + if __name__ == "__main__": main() diff --git a/chipfoundry_cli/preview.py b/chipfoundry_cli/preview.py new file mode 100644 index 0000000..6b9837a --- /dev/null +++ b/chipfoundry_cli/preview.py @@ -0,0 +1,920 @@ +"""`cf preview`: scan macros, classify LibreLane integration, optional lint+synth.""" + +from __future__ import annotations + +import json +import re +from decimal import Decimal +from pathlib import Path +from typing import Any, Dict, List, Optional, Sequence, Set, Tuple + +import click +from rich.console import Console +from rich.table import Table + +from chipfoundry_cli.librelane_run import ( + SYNTH_TO_STEP, + build_librelane_command, + detect_pdk, + find_config_file, + find_flow_root, + find_synth_stat_json, + librelane_venv, + list_macro_configs, + new_run_tag, + parse_synth_metrics, + pdk_root_dir, + resolve_execution_backend, + run_librelane, +) + +console = Console() + +STRATEGY_HIER_MACRO_FIRST = "hierarchical_macro_first" +STRATEGY_HIER_MACRO_FIRST_LEGACY = "hierarchical_macro_first_legacy" +STRATEGY_ANALOG_PG_WRAP = "analog_pg_wrap" +STRATEGY_FLATTENED_RTL = "flattened_rtl" +STRATEGY_HIER_RTL = "hierarchical_rtl" +STRATEGY_HARD_IP = "hard_ip" + +LINT_PASSED = "passed" +LINT_FAILED = "failed" +LINT_SKIPPED = "skipped" + +_ELABORATE_ONLY_STRATEGIES = { + STRATEGY_HIER_MACRO_FIRST, + STRATEGY_HIER_MACRO_FIRST_LEGACY, + STRATEGY_ANALOG_PG_WRAP, + STRATEGY_HARD_IP, +} + +_LEGACY_BLACKBOX_SKIP = {"defines.v"} + + +def _truthy(value: Any) -> bool: + if value is True or value == 1: + return True + if isinstance(value, str) and value.strip().lower() in {"1", "true", "yes", "on"}: + return True + return False + + +def _parse_config_file(path: Path) -> Dict[str, Any]: + raw = path.read_text() + suffix = path.suffix.lower() + if suffix == ".json": + parsed = json.loads(raw) + elif suffix in {".yaml", ".yml"}: + try: + import yaml # type: ignore[import-untyped] + except ImportError as exc: + raise ValueError( + f"Cannot parse {path}: PyYAML is not installed" + ) from exc + parsed = yaml.safe_load(raw) + else: + raise ValueError(f"Unsupported config format for hierarchy scan: {path}") + if not isinstance(parsed, dict): + raise ValueError(f"Macro config {path} must contain an object at its root") + return parsed + + +def die_area_mm2(config: Dict[str, Any]) -> Optional[Decimal]: + """Convert LibreLane DIE_AREA (µm) to mm². Nested PDK maps are not walked.""" + raw = config.get("DIE_AREA") + nums: List[float] = [] + if isinstance(raw, (list, tuple)) and len(raw) >= 4: + try: + nums = [float(x) for x in raw[:4]] + except (TypeError, ValueError) as exc: + raise ValueError(f"Invalid DIE_AREA list: {raw}") from exc + elif isinstance(raw, str): + parts = raw.replace(",", " ").split() + if len(parts) >= 4: + try: + nums = [float(p) for p in parts[:4]] + except ValueError as exc: + raise ValueError(f"Invalid DIE_AREA string: {raw}") from exc + if len(nums) < 4: + return None + width = abs(nums[2] - nums[0]) + height = abs(nums[3] - nums[1]) + um2 = width * height + if um2 <= 0: + return None + return (Decimal(str(um2)) / Decimal("1000000")).quantize(Decimal("0.0001")) + + +def _stem_from_view_path(value: Any) -> Optional[str]: + if not isinstance(value, str) or not value.strip(): + return None + name = Path(value.replace("dir::", "").strip()).name + if name in _LEGACY_BLACKBOX_SKIP: + return None + lower = name.lower() + for ext in (".gds.gz", ".lef", ".gds", ".v"): + if lower.endswith(ext): + return name[: -len(ext)] + stem = Path(name).stem + return stem or None + + +def _as_path_list(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + return [value] + if isinstance(value, list): + return [item for item in value if isinstance(item, str)] + return [] + + +def _macros_have_gds_lef(macros: Dict[str, Any]) -> bool: + for child in macros.values(): + if not isinstance(child, dict): + continue + if _as_path_list(child.get("gds")) or _as_path_list(child.get("lef")): + return True + return False + + +def classify_integration_strategy( + config: Dict[str, Any], + *, + child_names_with_configs: Set[str], +) -> str: + macros = config.get("MACROS") + has_macros = isinstance(macros, dict) and len(macros) > 0 + elaborate = _truthy(config.get("SYNTH_ELABORATE_ONLY")) + has_pdn = bool(config.get("PDN_MACRO_CONNECTIONS")) + has_abstract = bool(config.get("MAGIC_EXT_ABSTRACT_CELLS")) + has_blackbox = bool(config.get("VERILOG_FILES_BLACKBOX")) + has_extra_lef = bool(config.get("EXTRA_LEFS")) + has_extra_gds = bool(config.get("EXTRA_GDS_FILES")) + has_placement = bool(config.get("MACRO_PLACEMENT_CFG")) + has_legacy_pdn = bool(config.get("FP_PDN_MACRO_HOOKS")) + legacy_hier = has_blackbox or has_extra_lef or has_extra_gds or has_placement or has_legacy_pdn + + if elaborate and has_abstract and has_pdn: + return STRATEGY_ANALOG_PG_WRAP + if elaborate and has_macros and _macros_have_gds_lef(macros): + return STRATEGY_HIER_MACRO_FIRST + if elaborate and legacy_hier: + return STRATEGY_HIER_MACRO_FIRST_LEGACY + if has_macros: + child_keys = {str(k).strip() for k in macros.keys() if str(k).strip()} + if child_keys & child_names_with_configs and not _macros_have_gds_lef(macros): + return STRATEGY_HIER_RTL + if _macros_have_gds_lef(macros): + return STRATEGY_HIER_MACRO_FIRST + if child_keys & child_names_with_configs: + return STRATEGY_HIER_RTL + if legacy_hier and not elaborate: + return STRATEGY_HARD_IP + if has_macros: + return STRATEGY_HIER_RTL + return STRATEGY_FLATTENED_RTL + + +def _instance_count(child_config: Any) -> Optional[int]: + if not isinstance(child_config, dict): + return None + instances = child_config.get("instances") + if instances is None: + return 0 + if isinstance(instances, dict): + return len(instances) + if isinstance(instances, list): + return len(instances) + raise ValueError("MACROS instances must be an object or array") + + +def _parse_placement_cfg(path: Path) -> int: + count = 0 + for line in path.read_text().splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + count += 1 + return count + + +def read_ipm_dependencies(project_root: Path) -> List[Dict[str, str]]: + """Read ``ip/dependencies.json`` (IPM). Empty if the file is absent.""" + path = project_root / "ip" / "dependencies.json" + if not path.is_file(): + return [] + data = json.loads(path.read_text()) + if not isinstance(data, dict): + raise ValueError(f"{path} must contain a JSON object") + entries = data.get("IP") + if entries is None: + return [] + if not isinstance(entries, list): + raise ValueError(f"{path} field 'IP' must be an array") + out: List[Dict[str, str]] = [] + for index, item in enumerate(entries): + if not isinstance(item, dict) or len(item) != 1: + raise ValueError(f"{path} IP[{index}] must be a single-key object") + name, version = next(iter(item.items())) + if not isinstance(name, str) or not name.strip(): + raise ValueError(f"{path} IP[{index}] has an invalid name") + out.append({"name": name.strip(), "version": str(version), "source": "ipm"}) + return out + + +def _legacy_child_names(config: Dict[str, Any]) -> List[str]: + names: List[str] = [] + seen: Set[str] = set() + for key in ("EXTRA_LEFS", "EXTRA_GDS_FILES", "VERILOG_FILES_BLACKBOX"): + for raw in _as_path_list(config.get(key)): + stem = _stem_from_view_path(raw) + if stem and stem not in seen: + seen.add(stem) + names.append(stem) + return names + + +def scan_project(project_root: Path) -> Dict[str, Any]: + """Scan LibreLane configs, hierarchy, IPM, and integration strategy.""" + notes: List[str] = [] + discovered = list_macro_configs(project_root) + configs_by_dir_name = {row["name"]: row for row in discovered} + dir_names_with_configs = set(configs_by_dir_name) + + macros_by_name: Dict[str, Dict[str, Any]] = {} + edges: Set[Tuple[str, str]] = set() + parsed_by_dir: Dict[str, Dict[str, Any]] = {} + + for row in discovered: + path = project_root / row["config_path"] + if path.suffix.lower() == ".tcl": + notes.append( + f"Macro config {row['config_path']} is Tcl; hierarchy was not inferred from it" + ) + parent = row["name"] + macros_by_name.setdefault( + parent, + { + "name": parent, + "notes": f"from {row['config_path']}", + "is_top_level": False, + "source": "cli_preview", + "config_path": row["config_path"], + "estimated_area_mm2": None, + "gate_count": None, + "instances": [], + "integration_strategy": None, + "lint_status": None, + }, + ) + continue + try: + config = _parse_config_file(path) + except (ValueError, json.JSONDecodeError) as exc: + notes.append(str(exc)) + continue + parsed_by_dir[row["name"]] = config + + design_name = config.get("DESIGN_NAME") + if not isinstance(design_name, str) or not design_name.strip(): + notes.append(f"Macro config {row['config_path']} has no valid DESIGN_NAME") + parent = row["name"] + else: + parent = design_name.strip() + + area = die_area_mm2(config) + parent_row = macros_by_name.setdefault( + parent, + { + "name": parent, + "notes": f"from {row['config_path']}", + "is_top_level": False, + "source": "cli_preview", + "config_path": row["config_path"], + "estimated_area_mm2": str(area) if area is not None else None, + "gate_count": None, + "instances": [], + "integration_strategy": None, + "lint_status": None, + }, + ) + if parent_row["config_path"] != row["config_path"]: + notes.append( + f"Multiple configs declare DESIGN_NAME {parent!r}; using {parent_row['config_path']}" + ) + elif area is not None and parent_row["estimated_area_mm2"] is None: + parent_row["estimated_area_mm2"] = str(area) + + macro_section = config.get("MACROS") + if isinstance(macro_section, dict): + for child_name, child_config in macro_section.items(): + if not isinstance(child_name, str) or not child_name.strip(): + notes.append(f"Macro config {row['config_path']} contains an invalid MACROS key") + continue + child = child_name.strip() + try: + count = _instance_count(child_config) + except ValueError as exc: + notes.append(f"Macro {child!r} in {row['config_path']}: {exc}") + continue + if count is None: + notes.append(f"Macro {child!r} in {row['config_path']} is not an object") + continue + child_row = macros_by_name.setdefault( + child, + { + "name": child, + "notes": None, + "is_top_level": False, + "source": "cli_preview", + "config_path": None, + "estimated_area_mm2": None, + "gate_count": None, + "instances": [], + "integration_strategy": STRATEGY_HARD_IP, + "lint_status": None, + }, + ) + if count > 0: + child_row["instances"].append({"parent_macro": parent, "count": count}) + edges.add((parent, child)) + elif macro_section is not None: + notes.append(f"Macro config {row['config_path']} has MACROS that is not an object") + + placement_count = None + placement = config.get("MACRO_PLACEMENT_CFG") + if isinstance(placement, str) and placement.strip(): + rel = placement.replace("dir::", "") + placement_path = (path.parent / rel).resolve() + if placement_path.is_file(): + placement_count = _parse_placement_cfg(placement_path) + else: + notes.append(f"MACRO_PLACEMENT_CFG not found for {row['config_path']}: {placement}") + + for child in _legacy_child_names(config): + child_row = macros_by_name.setdefault( + child, + { + "name": child, + "notes": None, + "is_top_level": False, + "source": "cli_preview", + "config_path": None, + "estimated_area_mm2": None, + "gate_count": None, + "instances": [], + "integration_strategy": STRATEGY_HARD_IP, + "lint_status": None, + }, + ) + count = placement_count if placement_count and placement_count > 0 else 1 + if not any(inst["parent_macro"] == parent for inst in child_row["instances"]): + child_row["instances"].append({"parent_macro": parent, "count": count}) + edges.add((parent, child)) + + child_config_names: Set[str] = set() + for name in macros_by_name: + if name in dir_names_with_configs: + child_config_names.add(name) + # DESIGN_NAME may differ from directory name + for dir_name, config in parsed_by_dir.items(): + design = config.get("DESIGN_NAME") + if isinstance(design, str) and design.strip() == name: + child_config_names.add(name) + + for dir_name, config in parsed_by_dir.items(): + design = config.get("DESIGN_NAME") + parent = design.strip() if isinstance(design, str) and design.strip() else dir_name + strategy = classify_integration_strategy( + config, child_names_with_configs=child_config_names + ) + if parent in macros_by_name: + macros_by_name[parent]["integration_strategy"] = strategy + + if macros_by_name: + children = {child for _, child in edges} + roots = sorted(set(macros_by_name) - children) + if len(roots) == 1: + macros_by_name[roots[0]]["is_top_level"] = True + notes.append(f"Identified unique design hierarchy root: {roots[0]}") + elif len(roots) > 1: + notes.append( + "Multiple design hierarchy roots found; no macro was marked top-level: " + + ", ".join(roots) + ) + else: + notes.append("No design hierarchy root found; the macro graph may contain a cycle") + + ipm = read_ipm_dependencies(project_root) + marketplace_ip: List[Dict[str, str]] = list(ipm) + seen_ip = {item["name"] for item in marketplace_ip} + for name, row in macros_by_name.items(): + if row.get("integration_strategy") == STRATEGY_HARD_IP and name not in seen_ip: + marketplace_ip.append({"name": name, "version": "", "source": "config"}) + seen_ip.add(name) + elif name.startswith("CF_") and name not in seen_ip: + marketplace_ip.append({"name": name, "version": "", "source": "config"}) + seen_ip.add(name) + + if discovered: + notes.append(f"Found {len(discovered)} OpenLane/LibreLane macro config(s)") + if ipm: + notes.append(f"Found {len(ipm)} IPM dependenc(ies) in ip/dependencies.json") + + macros = [macros_by_name[name] for name in sorted(macros_by_name)] + return { + "macros": macros, + "marketplace_ip": marketplace_ip, + "notes": notes, + "discovered": discovered, + "parsed_by_dir": {k: True for k in parsed_by_dir}, + } + + +def strategy_uses_gate_count(strategy: Optional[str]) -> bool: + return strategy == STRATEGY_FLATTENED_RTL or strategy == STRATEGY_HIER_RTL + + +def find_verilator_lint_errors(flow_root: Path, macro: str, run_tag: str) -> Optional[int]: + run_dir = flow_root / macro / "runs" / run_tag + if not run_dir.is_dir(): + return None + for path in run_dir.rglob("*"): + if not path.is_file(): + continue + name = path.name.lower() + if "lint" in name and path.suffix in {".rpt", ".log", ".txt"}: + text = path.read_text(errors="replace") + errors = len(re.findall(r"\berror\b", text, flags=re.IGNORECASE)) + return errors + metrics = run_dir / "final" / "metrics.json" + if metrics.is_file(): + data = json.loads(metrics.read_text()) + if isinstance(data, dict) and "verilator__lint_error__count" in data: + return int(float(data["verilator__lint_error__count"])) + return 0 + + +def apply_synth_metrics( + macro: Dict[str, Any], + metrics: Dict[str, Any], + *, + lint_errors: Optional[int], +) -> None: + strategy = macro.get("integration_strategy") + if lint_errors is None: + macro["lint_status"] = LINT_SKIPPED + elif lint_errors == 0: + macro["lint_status"] = LINT_PASSED + else: + macro["lint_status"] = LINT_FAILED + if strategy_uses_gate_count(strategy): + macro["gate_count"] = metrics["gate_count"] + area = metrics.get("estimated_area_mm2") + if area is not None: + macro["estimated_area_mm2"] = str(area) + elif macro.get("estimated_area_mm2") is None and metrics.get("estimated_area_mm2") is not None: + # Keep DIE_AREA / catalog area; do not treat Yosys cells as gates. + pass + + +def leaf_summary(macros: Sequence[Dict[str, Any]]) -> Dict[str, Any]: + parent_names = { + inst["parent_macro"] + for row in macros + for inst in row.get("instances") or [] + } + known_area = Decimal("0") + known_gates = 0 + missing_area = 0 + missing_gates = 0 + instantiated = 0 + leaf_count = 0 + for row in macros: + is_leaf = (not row.get("is_top_level")) and row["name"] not in parent_names + if not is_leaf: + continue + leaf_count += 1 + instance_count = sum(int(inst["count"]) for inst in (row.get("instances") or [])) + if instance_count == 0: + continue + instantiated += 1 + area = row.get("estimated_area_mm2") + if area in (None, ""): + missing_area += 1 + else: + known_area += Decimal(str(area)) * instance_count + gates = row.get("gate_count") + if gates is None: + missing_gates += 1 + else: + known_gates += int(gates) * instance_count + total_area = known_area if instantiated > 0 and missing_area == 0 else None + total_gates = known_gates if instantiated > 0 and missing_gates == 0 else None + return { + "leaf_macro_count": leaf_count, + "instantiated_leaf_macro_count": instantiated, + "known_area_mm2": str(known_area), + "total_area_mm2": str(total_area) if total_area is not None else None, + "missing_area_macro_count": missing_area, + "known_gate_count": known_gates, + "total_gate_count": total_gates, + "missing_gate_count_macro_count": missing_gates, + } + + +def print_preview_report(scan: Dict[str, Any], summary: Dict[str, Any]) -> None: + table = Table(title="Project preview", show_lines=False) + table.add_column("Macro", style="cyan") + table.add_column("Strategy") + table.add_column("Hierarchy") + table.add_column("Gates", justify="right") + table.add_column("Area mm²", justify="right") + table.add_column("Lint") + for row in scan["macros"]: + if row.get("is_top_level"): + hier = "Top level" + elif row.get("instances"): + hier = ", ".join( + f"{inst['count']}× by {inst['parent_macro']}" + for inst in row["instances"] + ) + else: + hier = "Not instantiated" + gates = "" if row.get("gate_count") is None else f"{row['gate_count']:,}" + area = row.get("estimated_area_mm2") or "—" + table.add_row( + row["name"], + row.get("integration_strategy") or "—", + hier, + gates or "—", + str(area), + row.get("lint_status") or "—", + ) + console.print(table) + + ip_table = Table(title="Marketplace / IPM IP", show_lines=False) + ip_table.add_column("Name", style="cyan") + ip_table.add_column("Version") + ip_table.add_column("Detected from") + if scan["marketplace_ip"]: + for item in scan["marketplace_ip"]: + ip_table.add_row(item["name"], item.get("version") or "—", item.get("source") or "—") + console.print(ip_table) + else: + console.print("[yellow]No marketplace IP or IPM dependencies detected[/yellow]") + + console.print( + f"[bold]Leaf macros:[/bold] {summary['leaf_macro_count']} " + f"instantiated {summary['instantiated_leaf_macro_count']} " + f"known gates {summary['known_gate_count']:,} " + f"known area {summary['known_area_mm2']} mm²" + ) + for note in scan.get("notes") or []: + console.print(f"[dim]• {note}[/dim]") + + +def run_local_synth_for_macros( + project_root: Path, + scan: Dict[str, Any], + *, + only_macro: Optional[str], + pdk: Optional[str], + use_nix: bool, + use_docker: bool, + dry_run: bool, +) -> None: + from chipfoundry_cli.utils import fetch_versions_from_upstream + + flow_root = find_flow_root(project_root) + if flow_root is None: + raise click.ClickException("No openlane/ or librelane/ directory found") + venv = librelane_venv(flow_root) + if not venv.exists(): + raise click.ClickException( + "LibreLane not installed. Run 'cf setup --only-openlane' or pass --skip-synth." + ) + pdk_name = detect_pdk(project_root, pdk) + pdk_root = pdk_root_dir(project_root) + if not (pdk_root / pdk_name).exists(): + raise click.ClickException( + f"PDK not found: {pdk_root / pdk_name}. Run 'cf setup --only-pdk' or pass --skip-synth." + ) + versions = fetch_versions_from_upstream("chipfoundry", "cf-cli", "main") + openlane_version = versions["openlane_version"] + backend, err = resolve_execution_backend( + openlane_version=openlane_version, + force_nix=use_nix, + force_docker=use_docker, + ) + if err: + raise click.ClickException(err) + + targets: List[Dict[str, Any]] = [] + for macro in scan["macros"]: + if only_macro and macro["name"] != only_macro: + continue + if not macro.get("config_path"): + continue + if not strategy_uses_gate_count(macro.get("integration_strategy")): + macro["lint_status"] = macro.get("lint_status") or LINT_SKIPPED + continue + targets.append(macro) + + if only_macro and not any(m["name"] == only_macro for m in scan["macros"]): + raise click.ClickException(f"Macro not found: {only_macro}") + + for macro in targets: + config_rel = macro["config_path"] + config_file = project_root / config_rel + dir_name = Path(config_rel).parent.name + tag = new_run_tag() + console.print(f"[cyan]Lint + synthesis:[/cyan] {macro['name']} → {SYNTH_TO_STEP} (tag {tag})") + cmd, env = build_librelane_command( + project_root=project_root, + flow_root=flow_root, + config_file=config_file, + pdk=pdk_name, + pdk_root=pdk_root, + tag=tag, + openlane_version=openlane_version, + backend=backend, + to_step=SYNTH_TO_STEP, + overwrite=True, + ) + if dry_run: + console.print("[dim]" + " ".join(cmd) + "[/dim]") + continue + code = run_librelane(cmd, cwd=flow_root, env=env) + if code != 0: + raise click.ClickException(f"LibreLane preview failed for {macro['name']} (exit {code})") + stat_path = find_synth_stat_json(flow_root, dir_name, tag) + if stat_path is None: + raise click.ClickException( + f"Synthesizable macro {macro['name']} finished with no stat.json" + ) + metrics = parse_synth_metrics(stat_path) + lint_errors = find_verilator_lint_errors(flow_root, dir_name, tag) + apply_synth_metrics(macro, metrics, lint_errors=lint_errors) + + +def build_preview_payload( + scan: Dict[str, Any], + *, + synth_mode: str, + git_sha: Optional[str], + cli_version: str, +) -> Dict[str, Any]: + macros = [] + for row in scan["macros"]: + item = { + "name": row["name"], + "notes": row.get("notes"), + "is_top_level": bool(row.get("is_top_level")), + "source": "cli_preview", + "config_path": row.get("config_path"), + "estimated_area_mm2": row.get("estimated_area_mm2"), + "gate_count": row.get("gate_count"), + "instances": row.get("instances") or [], + "integration_strategy": row.get("integration_strategy"), + "lint_status": row.get("lint_status"), + } + macros.append(item) + return { + "macros": macros, + "marketplace_ip": scan.get("marketplace_ip") or [], + "notes": scan.get("notes") or [], + "git_sha": git_sha, + "cli_version": cli_version, + "synth_mode": synth_mode, + "summary": leaf_summary(scan["macros"]), + } + + +def merge_remote_job_metrics(scan: Dict[str, Any], job: Dict[str, Any]) -> None: + """Apply pnr_results stats from a completed preview job onto the matching macro.""" + macro_name = job.get("macro") + results = job.get("pnr_results") or {} + stats = results.get("stats") if isinstance(results, dict) else None + if not macro_name or not isinstance(stats, dict): + return + gate_count = stats.get("instance_count") or stats.get("design__instance__count") + area = stats.get("instance_area") or stats.get("design__instance__area") + metrics: Dict[str, Any] = {} + if gate_count is not None: + metrics["gate_count"] = int(float(gate_count)) + if area not in (None, "", 0, 0.0): + # LibreLane instance area is µm². + metrics["estimated_area_mm2"] = float(area) / 1_000_000.0 + if "gate_count" not in metrics: + return + for macro in scan["macros"]: + if macro["name"] != macro_name: + continue + apply_synth_metrics(macro, metrics, lint_errors=0) + return + + +def register_preview(main_group) -> None: + @main_group.command("preview") + @click.option( + "--project-root", + type=click.Path(exists=True, file_okay=False), + help="Path to the project directory (defaults to current directory)", + ) + @click.option("--macro", help="Only run lint+synth for this macro (scan still covers the project)") + @click.option("--skip-synth", is_flag=True, help="Scan and classify only; do not run LibreLane") + @click.option("--no-push", is_flag=True, help="Print the report without posting to the platform") + @click.option("--pdk", help="PDK to use (defaults to project.json or sky130A)") + @click.option("--use-nix", is_flag=True, help="Force Nix for local lint+synth") + @click.option("--use-docker", is_flag=True, help="Force Docker for local lint+synth") + @click.option("--dry-run", is_flag=True, help="Show LibreLane commands without running them") + @click.option("--remote", is_flag=True, help="Queue synth-only PNR jobs on the platform") + @click.option("--poll", is_flag=True, help="With --remote: wait until jobs finish") + @click.option("--git-ref", default="main", show_default=True, help="Git ref for remote preview jobs") + @click.option( + "--wait-timeout", + type=int, + default=7200, + show_default=True, + help="With --remote --poll: max seconds to wait (0 = no limit)", + ) + def preview( + project_root, + macro, + skip_synth, + no_push, + pdk, + use_nix, + use_docker, + dry_run, + remote, + poll, + git_ref, + wait_timeout, + ): + """Scan macros, IP, and LibreLane strategy; optionally lint+synthesize. + + Examples: + cf preview + cf preview --skip-synth + cf preview --macro user_proj_example + cf preview --remote --poll + cf preview --no-push + """ + from chipfoundry_cli.main import ( + _api_post, + _load_project_platform_id, + _queue_and_maybe_poll_remote_job, + check_project_initialized, + get_project_json_from_cwd, + ) + from chipfoundry_cli.utils import get_head_commit_sha + import importlib.metadata + + cwd_root, _ = get_project_json_from_cwd() + if not project_root and cwd_root: + project_root = cwd_root + if not project_root: + project_root = os_getcwd() + project_root_path = Path(project_root) + + if poll and not remote: + console.print("[red]✗[/red] --poll requires --remote.") + raise SystemExit(1) + if remote and skip_synth: + console.print("[red]✗[/red] --remote cannot be combined with --skip-synth.") + raise SystemExit(1) + if remote and (use_nix or use_docker): + console.print("[red]✗[/red] --remote cannot be combined with --use-nix or --use-docker.") + raise SystemExit(1) + + if not check_project_initialized( + project_root_path, "preview", dry_run=dry_run, allow_graceful=True + ): + console.print("[red]✗[/red] Project not initialized. Please run 'cf init' first.") + return + + scan = scan_project(project_root_path) + if not scan["macros"] and not scan["marketplace_ip"]: + console.print("[yellow]No LibreLane macros or IPM dependencies found[/yellow]") + + synth_mode = "skipped" + if skip_synth: + for row in scan["macros"]: + row["lint_status"] = LINT_SKIPPED + elif remote: + synth_mode = "remote" + else: + synth_mode = "local" + if not dry_run: + run_local_synth_for_macros( + project_root_path, + scan, + only_macro=macro, + pdk=pdk, + use_nix=use_nix, + use_docker=use_docker, + dry_run=False, + ) + else: + run_local_synth_for_macros( + project_root_path, + scan, + only_macro=macro, + pdk=pdk, + use_nix=use_nix, + use_docker=use_docker, + dry_run=True, + ) + + summary = leaf_summary(scan["macros"]) + print_preview_report(scan, summary) + + git_sha = None + try: + git_sha = get_head_commit_sha(str(project_root_path)) + except Exception: + git_sha = None + cli_version = importlib.metadata.version("chipfoundry-cli") + payload = build_preview_payload( + scan, + synth_mode=synth_mode, + git_sha=git_sha, + cli_version=cli_version, + ) + + platform_id = _load_project_platform_id(str(project_root_path)) + if remote: + if not platform_id: + console.print( + "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])." + ) + raise SystemExit(1) + if not no_push: + _api_post(f"/projects/{platform_id}/preview", payload) + console.print("[green]✓ Preview scan synced to platform[/green]") + targets = [ + row + for row in scan["macros"] + if row.get("config_path") and strategy_uses_gate_count(row.get("integration_strategy")) + ] + if macro: + targets = [row for row in targets if row["name"] == macro] + from chipfoundry_cli.remote_precheck_git import ( + RemotePrecheckGitError, + verify_remote_job_repo, + ) + + try: + verify_remote_job_repo(project_root_path, git_ref) + except RemotePrecheckGitError as e: + console.print(f"[red]✗[/red] {e}") + raise SystemExit(1) + for row in targets: + params = [ + ("macro", row["name"]), + ("git_ref", git_ref), + ("purpose", "preview"), + ("to_step", SYNTH_TO_STEP), + ] + if pdk: + params.append(("pdk", pdk)) + job = _queue_and_maybe_poll_remote_job( + create_path=f"/projects/{platform_id}/pnr-jobs", + job_get_path_template=f"/projects/{platform_id}/pnr-jobs/{{jid}}", + params=params, + dry_run=dry_run, + poll=poll, + wait_timeout=wait_timeout, + label=f"Remote preview ({row['name']})", + ) + if poll and isinstance(job, dict): + merge_remote_job_metrics(scan, job) + if poll and not no_push and not dry_run: + payload = build_preview_payload( + scan, + synth_mode="remote", + git_sha=git_sha, + cli_version=cli_version, + ) + _api_post(f"/projects/{platform_id}/preview", payload) + console.print("[green]✓ Preview synthesis metrics synced to platform[/green]") + return + + if no_push or dry_run: + return + if not platform_id: + console.print( + "[yellow]⚠ Preview not synced: link this repo with [bold]cf link[/bold][/yellow]" + ) + return + _api_post(f"/projects/{platform_id}/preview", payload) + console.print("[green]✓ Preview results synced to platform[/green]") + + +def os_getcwd() -> str: + import os + + return os.getcwd() diff --git a/pyproject.toml b/pyproject.toml index 9b47c11..3f5a3f5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "chipfoundry-cli" -version = "2.5.4" +version = "2.6.0" description = "CLI tool to automate ChipFoundry project submission to SFTP server" authors = ["ChipFoundry "] readme = "README.md" diff --git a/tests/test_preview_command.py b/tests/test_preview_command.py new file mode 100644 index 0000000..fd312ad --- /dev/null +++ b/tests/test_preview_command.py @@ -0,0 +1,243 @@ +"""Tests for cf preview scan, strategy classification, and command wiring.""" + +import json +from pathlib import Path + +from click.testing import CliRunner + +from chipfoundry_cli.librelane_run import parse_synth_metrics +from chipfoundry_cli.main import main +from chipfoundry_cli.preview import ( + STRATEGY_ANALOG_PG_WRAP, + STRATEGY_FLATTENED_RTL, + STRATEGY_HARD_IP, + STRATEGY_HIER_MACRO_FIRST, + STRATEGY_HIER_MACRO_FIRST_LEGACY, + apply_synth_metrics, + classify_integration_strategy, + die_area_mm2, + leaf_summary, + scan_project, +) + + +def _write(path: Path, contents: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(contents) + + +def _init_project(root: Path) -> None: + _write( + root / ".cf" / "project.json", + json.dumps({"project": {"name": "demo", "platform_project_id": None}}), + ) + + +def test_preview_help(): + runner = CliRunner() + result = runner.invoke(main, ["preview", "--help"]) + assert result.exit_code == 0 + assert "Scan macros" in result.output + assert "--skip-synth" in result.output + assert "--no-push" in result.output + assert "--remote" in result.output + + +def test_die_area_list_and_string(): + assert str(die_area_mm2({"DIE_AREA": [0, 0, 1000, 2000]})) == "2.0000" + assert str(die_area_mm2({"DIE_AREA": "0 0 2920 3520"})) == "10.2784" + assert die_area_mm2({}) is None + + +def test_classify_caravel_wrapper_and_leaf(): + wrapper = { + "SYNTH_ELABORATE_ONLY": True, + "MACROS": { + "user_proj_example": { + "gds": ["dir::../../gds/user_proj_example.gds"], + "lef": ["dir::../../lef/user_proj_example.lef"], + "instances": {"mprj": {}}, + } + }, + } + leaf = {"DESIGN_NAME": "user_proj_example", "VERILOG_FILES": ["a.v"]} + analog = { + "SYNTH_ELABORATE_ONLY": True, + "PDN_MACRO_CONNECTIONS": ["u_bgr vccd1 vssd1 vpwr vgnd"], + "MAGIC_EXT_ABSTRACT_CELLS": ["^CF_BGR_core$"], + "MACROS": { + "CF_BGR": { + "gds": ["dir::gds/CF_BGR.gds"], + "lef": ["dir::lef/CF_BGR.lef"], + } + }, + } + legacy = { + "SYNTH_ELABORATE_ONLY": 1, + "VERILOG_FILES_BLACKBOX": ["dir::../../verilog/rtl/user_proj_example.v"], + "EXTRA_LEFS": "dir::../../lef/user_proj_example.lef", + "EXTRA_GDS_FILES": "dir::../../gds/user_proj_example.gds", + "MACRO_PLACEMENT_CFG": "dir::macro.cfg", + "FP_PDN_MACRO_HOOKS": "mprj vccd1 vssd1 vccd1 vssd1", + } + kids = {"user_proj_example", "CF_BGR"} + assert classify_integration_strategy(wrapper, child_names_with_configs=kids) == STRATEGY_HIER_MACRO_FIRST + assert classify_integration_strategy(leaf, child_names_with_configs=kids) == STRATEGY_FLATTENED_RTL + assert classify_integration_strategy(analog, child_names_with_configs=kids) == STRATEGY_ANALOG_PG_WRAP + assert ( + classify_integration_strategy(legacy, child_names_with_configs=kids) + == STRATEGY_HIER_MACRO_FIRST_LEGACY + ) + + +def test_scan_hierarchy_and_ipm(tmp_path: Path): + _init_project(tmp_path) + _write( + tmp_path / "openlane" / "user_project_wrapper" / "config.json", + json.dumps( + { + "DESIGN_NAME": "user_project_wrapper", + "SYNTH_ELABORATE_ONLY": True, + "DIE_AREA": [0, 0, 2920, 3520], + "MACROS": { + "user_proj_example": { + "gds": ["dir::../../gds/user_proj_example.gds"], + "lef": ["dir::../../lef/user_proj_example.lef"], + "instances": {"mprj": {"location": [60, 15]}}, + } + }, + } + ), + ) + _write( + tmp_path / "openlane" / "user_proj_example" / "config.json", + json.dumps( + { + "DESIGN_NAME": "user_proj_example", + "VERILOG_FILES": ["dir::../../verilog/rtl/user_proj_example.v"], + "DIE_AREA": [0, 0, 2800, 1760], + } + ), + ) + _write( + tmp_path / "ip" / "dependencies.json", + json.dumps({"IP": [{"CF_SRAM_1024x32": "1.2.3"}]}), + ) + scan = scan_project(tmp_path) + by_name = {row["name"]: row for row in scan["macros"]} + assert by_name["user_project_wrapper"]["is_top_level"] is True + assert by_name["user_project_wrapper"]["integration_strategy"] == STRATEGY_HIER_MACRO_FIRST + assert by_name["user_proj_example"]["integration_strategy"] == STRATEGY_FLATTENED_RTL + assert by_name["user_proj_example"]["instances"] == [ + {"parent_macro": "user_project_wrapper", "count": 1} + ] + assert any(item["name"] == "CF_SRAM_1024x32" for item in scan["marketplace_ip"]) + + +def test_scan_legacy_extra_lefs(tmp_path: Path): + _init_project(tmp_path) + wrapper_dir = tmp_path / "openlane" / "openframe_project_wrapper" + _write( + wrapper_dir / "config.json", + json.dumps( + { + "DESIGN_NAME": "openframe_project_wrapper", + "SYNTH_ELABORATE_ONLY": 1, + "VERILOG_FILES_BLACKBOX": [ + "dir::../../verilog/rtl/defines.v", + "dir::../../verilog/rtl/user_proj_example.v", + ], + "EXTRA_LEFS": "dir::../../lef/user_proj_example.lef", + "MACRO_PLACEMENT_CFG": "dir::macro.cfg", + } + ), + ) + _write(wrapper_dir / "macro.cfg", "mprj 1175 1690 N\n") + scan = scan_project(tmp_path) + by_name = {row["name"]: row for row in scan["macros"]} + assert by_name["openframe_project_wrapper"]["integration_strategy"] == STRATEGY_HIER_MACRO_FIRST_LEGACY + assert by_name["user_proj_example"]["instances"][0]["count"] == 1 + assert by_name["user_proj_example"]["integration_strategy"] == STRATEGY_HARD_IP + + +def test_tcl_config_is_listed_without_hierarchy(tmp_path: Path): + _init_project(tmp_path) + _write(tmp_path / "openlane" / "legacy" / "config.tcl", "set ::env(DESIGN_NAME) legacy\n") + scan = scan_project(tmp_path) + assert scan["macros"][0]["name"] == "legacy" + assert scan["macros"][0]["integration_strategy"] is None + assert any("Tcl" in note for note in scan["notes"]) + + +def test_parse_synth_metrics_and_skip_gates_for_wrapper(tmp_path: Path): + stat = tmp_path / "stat.json" + stat.write_text(json.dumps({"design": {"num_cells": 1234, "area": 50000}})) + metrics = parse_synth_metrics(stat) + assert metrics["gate_count"] == 1234 + assert abs(metrics["estimated_area_mm2"] - 0.05) < 1e-9 + + wrapper = { + "name": "user_project_wrapper", + "integration_strategy": STRATEGY_HIER_MACRO_FIRST, + "gate_count": None, + "estimated_area_mm2": "10.2784", + } + apply_synth_metrics(wrapper, metrics, lint_errors=0) + assert wrapper["gate_count"] is None + assert wrapper["lint_status"] == "passed" + assert wrapper["estimated_area_mm2"] == "10.2784" + + leaf = { + "name": "user_proj_example", + "integration_strategy": STRATEGY_FLATTENED_RTL, + "gate_count": None, + "estimated_area_mm2": None, + } + apply_synth_metrics(leaf, metrics, lint_errors=0) + assert leaf["gate_count"] == 1234 + + +def test_leaf_summary_partial_metrics(): + macros = [ + { + "name": "top", + "is_top_level": True, + "instances": [], + "gate_count": None, + "estimated_area_mm2": None, + }, + { + "name": "leaf_a", + "is_top_level": False, + "instances": [{"parent_macro": "top", "count": 2}], + "gate_count": 100, + "estimated_area_mm2": "0.1", + }, + { + "name": "leaf_b", + "is_top_level": False, + "instances": [{"parent_macro": "top", "count": 1}], + "gate_count": None, + "estimated_area_mm2": None, + }, + ] + summary = leaf_summary(macros) + assert summary["known_gate_count"] == 200 + assert summary["total_gate_count"] is None + assert summary["missing_gate_count_macro_count"] == 1 + + +def test_preview_skip_synth_no_push(tmp_path: Path): + _init_project(tmp_path) + _write( + tmp_path / "openlane" / "user_proj_example" / "config.json", + json.dumps({"DESIGN_NAME": "user_proj_example", "VERILOG_FILES": ["a.v"]}), + ) + runner = CliRunner() + result = runner.invoke( + main, + ["preview", "--project-root", str(tmp_path), "--skip-synth", "--no-push"], + ) + assert result.exit_code == 0, result.output + assert "user_proj_example" in result.output + assert "flattened_rtl" in result.output