chipfoundry-cli 2.5.4__tar.gz → 2.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: chipfoundry-cli
3
- Version: 2.5.4
3
+ Version: 2.6.0
4
4
  Summary: CLI tool to automate ChipFoundry project submission to SFTP server
5
5
  Home-page: https://chipfoundry.io
6
6
  License: Apache-2.0
@@ -1,2 +1,2 @@
1
1
  """ChipFoundry CLI package: Automate project submission to SFTP."""
2
- __version__ = "2.5.4"
2
+ __version__ = "2.6.0"
@@ -0,0 +1,294 @@
1
+ """Shared LibreLane invocation helpers for `cf harden` and `cf preview`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import subprocess
9
+ import sys
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Sequence, Tuple
13
+
14
+ CONFIG_EXTS = ("json", "yaml", "yml", "tcl")
15
+ OPENLANE_DIR_NAMES = ("openlane", "librelane")
16
+ SYNTH_TO_STEP = "Yosys.Synthesis"
17
+
18
+
19
+ def find_flow_root(project_root: Path) -> Optional[Path]:
20
+ """Return the first existing openlane/ or librelane/ directory."""
21
+ for name in OPENLANE_DIR_NAMES:
22
+ candidate = project_root / name
23
+ if candidate.is_dir():
24
+ return candidate
25
+ return None
26
+
27
+
28
+ def find_config_file(macro_dir: Path) -> Optional[Path]:
29
+ for ext in CONFIG_EXTS:
30
+ path = macro_dir / f"config.{ext}"
31
+ if path.is_file():
32
+ return path
33
+ return None
34
+
35
+
36
+ def list_macro_configs(project_root: Path) -> List[Dict[str, str]]:
37
+ """Discover LibreLane macros as ``{name, config_path, flow_root}``."""
38
+ found: List[Dict[str, str]] = []
39
+ seen = set()
40
+ for dir_name in OPENLANE_DIR_NAMES:
41
+ flow_root = project_root / dir_name
42
+ if not flow_root.is_dir():
43
+ continue
44
+ for child in sorted(flow_root.iterdir()):
45
+ if not child.is_dir() or child.name.startswith("."):
46
+ continue
47
+ config = find_config_file(child)
48
+ if config is None:
49
+ continue
50
+ rel = str(config.relative_to(project_root))
51
+ if child.name in seen:
52
+ continue
53
+ seen.add(child.name)
54
+ found.append(
55
+ {
56
+ "name": child.name,
57
+ "config_path": rel,
58
+ "flow_root": dir_name,
59
+ }
60
+ )
61
+ return found
62
+
63
+
64
+ def detect_pdk(project_root: Path, pdk_override: Optional[str] = None) -> str:
65
+ if pdk_override:
66
+ return pdk_override
67
+ project_json = project_root / ".cf" / "project.json"
68
+ if project_json.is_file():
69
+ data = json.loads(project_json.read_text())
70
+ pdk = data.get("pdk")
71
+ if isinstance(pdk, str) and pdk.strip():
72
+ return pdk.strip()
73
+ return "sky130A"
74
+
75
+
76
+ def pdk_root_dir(project_root: Path) -> Path:
77
+ return project_root / "dependencies" / "pdks"
78
+
79
+
80
+ def librelane_venv(flow_root: Path) -> Path:
81
+ return flow_root / ".venv"
82
+
83
+
84
+ def resolve_execution_backend(
85
+ *,
86
+ openlane_version: str,
87
+ force_nix: bool = False,
88
+ force_docker: bool = False,
89
+ ) -> Tuple[str, Optional[str]]:
90
+ """Return ``(\"nix\"|\"docker\", error)``."""
91
+ if force_nix and force_docker:
92
+ return "", "Cannot use both --use-nix and --use-docker"
93
+
94
+ use_nix = False
95
+ if force_nix or not force_docker:
96
+ if shutil.which("nix") is not None:
97
+ try:
98
+ result = subprocess.run(
99
+ [
100
+ "nix",
101
+ "flake",
102
+ "metadata",
103
+ f"github:chipfoundry/openlane-2/{openlane_version}",
104
+ "--json",
105
+ ],
106
+ capture_output=True,
107
+ timeout=5,
108
+ )
109
+ use_nix = result.returncode == 0
110
+ except (OSError, subprocess.TimeoutExpired):
111
+ use_nix = False
112
+ if force_nix and not use_nix:
113
+ return "", "Nix not available or cannot access LibreLane flake"
114
+
115
+ if not use_nix:
116
+ use_docker = False
117
+ try:
118
+ result = subprocess.run(
119
+ ["docker", "info"],
120
+ capture_output=True,
121
+ timeout=5,
122
+ )
123
+ use_docker = result.returncode == 0
124
+ except (OSError, subprocess.TimeoutExpired):
125
+ use_docker = False
126
+ if force_docker and not use_docker:
127
+ return "", "Docker not available"
128
+ if use_docker:
129
+ return "docker", None
130
+ if not force_nix:
131
+ return "", "Neither Nix nor Docker is available"
132
+
133
+ return "nix", None
134
+
135
+
136
+ def build_librelane_command(
137
+ *,
138
+ project_root: Path,
139
+ flow_root: Path,
140
+ config_file: Path,
141
+ pdk: str,
142
+ pdk_root: Path,
143
+ tag: str,
144
+ openlane_version: str,
145
+ backend: str,
146
+ from_step: Optional[str] = None,
147
+ to_step: Optional[str] = None,
148
+ overwrite: bool = True,
149
+ ) -> Tuple[List[str], Dict[str, str]]:
150
+ """Build a Nix or Docker LibreLane command. Does not include GUI flows."""
151
+ env = os.environ.copy()
152
+ env.update(
153
+ {
154
+ "PROJECT_ROOT": str(project_root),
155
+ "PDK_ROOT": str(pdk_root),
156
+ "PDK": pdk,
157
+ "LIBRELANE_RUN_TAG": tag,
158
+ }
159
+ )
160
+
161
+ if backend == "nix":
162
+ cmd = [
163
+ "nix",
164
+ "run",
165
+ f"github:chipfoundry/openlane-2/{openlane_version}",
166
+ "--",
167
+ "--manual-pdk",
168
+ "--pdk-root",
169
+ str(pdk_root),
170
+ "--pdk",
171
+ pdk,
172
+ "--ef-save-views-to",
173
+ str(project_root),
174
+ "--run-tag",
175
+ tag,
176
+ ]
177
+ else:
178
+ venv_bin = librelane_venv(flow_root) / "bin"
179
+ env["PATH"] = f"{venv_bin}:{env.get('PATH', '')}"
180
+ py_major = sys.version_info.major
181
+ py_minor = sys.version_info.minor
182
+ env["PYTHONPATH"] = str(
183
+ librelane_venv(flow_root)
184
+ / "lib"
185
+ / f"python{py_major}.{py_minor}"
186
+ / "site-packages"
187
+ )
188
+ cmd = [
189
+ str(venv_bin / "python3"),
190
+ "-m",
191
+ "librelane",
192
+ "-m",
193
+ str(project_root),
194
+ "-m",
195
+ str(pdk_root),
196
+ ]
197
+ try:
198
+ if not sys.stdin.isatty():
199
+ cmd.append("--docker-no-tty")
200
+ except Exception:
201
+ cmd.append("--docker-no-tty")
202
+ cmd.append("--dockerized")
203
+ cmd.extend(
204
+ [
205
+ "--manual-pdk",
206
+ "--pdk-root",
207
+ str(pdk_root),
208
+ "--pdk",
209
+ pdk,
210
+ "--ef-save-views-to",
211
+ str(project_root),
212
+ "--run-tag",
213
+ tag,
214
+ ]
215
+ )
216
+
217
+ if overwrite and not from_step:
218
+ cmd.append("--overwrite")
219
+ if from_step:
220
+ cmd.extend(["--from", from_step])
221
+ if to_step:
222
+ cmd.extend(["--to", to_step])
223
+ cmd.append(str(config_file))
224
+ return cmd, env
225
+
226
+
227
+ def new_run_tag() -> str:
228
+ return datetime.now().strftime("%y_%m_%d_%H_%M")
229
+
230
+
231
+ def find_synth_stat_json(flow_root: Path, macro: str, run_tag: str) -> Optional[Path]:
232
+ """Locate Yosys stat.json written by a synth-only or full LibreLane run."""
233
+ run_dir = flow_root / macro / "runs" / run_tag
234
+ candidates = [
235
+ run_dir / "Yosys.Synthesis" / "reports" / "stat.json",
236
+ run_dir / "05-yosys-synthesis" / "reports" / "stat.json",
237
+ run_dir / "final" / "metrics.json",
238
+ ]
239
+ if run_dir.is_dir():
240
+ for path in sorted(run_dir.rglob("stat.json")):
241
+ candidates.append(path)
242
+ for path in sorted(run_dir.rglob("metrics.json")):
243
+ candidates.append(path)
244
+ for path in candidates:
245
+ if path.is_file():
246
+ return path
247
+ return None
248
+
249
+
250
+ def parse_synth_metrics(stat_path: Path) -> Dict[str, object]:
251
+ """Parse Yosys stat.json or LibreLane metrics.json into gate/area fields.
252
+
253
+ Raises ValueError if the file cannot be parsed or has no cell count.
254
+ """
255
+ raw = json.loads(stat_path.read_text())
256
+ if not isinstance(raw, dict):
257
+ raise ValueError(f"Synth metrics at {stat_path} must be a JSON object")
258
+
259
+ gate_count: Optional[int] = None
260
+ area_um2: Optional[float] = None
261
+
262
+ design = raw.get("design")
263
+ if isinstance(design, dict):
264
+ cells = design.get("num_cells")
265
+ if cells is not None:
266
+ gate_count = int(cells)
267
+ area = design.get("area")
268
+ if area not in (None, 0, 0.0, "0"):
269
+ area_um2 = float(area)
270
+
271
+ if gate_count is None and "design__instance__count" in raw:
272
+ gate_count = int(float(raw["design__instance__count"]))
273
+ if area_um2 is None and raw.get("design__instance__area") not in (None, "", 0, 0.0):
274
+ area_um2 = float(raw["design__instance__area"])
275
+
276
+ if gate_count is None:
277
+ raise ValueError(f"No cell count in synth metrics at {stat_path}")
278
+
279
+ area_mm2 = (area_um2 / 1_000_000.0) if area_um2 is not None else None
280
+ return {
281
+ "gate_count": gate_count,
282
+ "estimated_area_mm2": area_mm2,
283
+ "metrics_path": str(stat_path),
284
+ }
285
+
286
+
287
+ def run_librelane(
288
+ cmd: Sequence[str],
289
+ *,
290
+ cwd: Path,
291
+ env: Dict[str, str],
292
+ ) -> int:
293
+ process = subprocess.Popen(list(cmd), cwd=str(cwd), env=env)
294
+ return process.wait()
@@ -3801,8 +3801,12 @@ def _queue_and_maybe_poll_remote_job(
3801
3801
  poll: bool,
3802
3802
  wait_timeout: int,
3803
3803
  label: str,
3804
- ) -> None:
3805
- """POST a remote platform job and optionally poll until terminal status."""
3804
+ ):
3805
+ """POST a remote platform job and optionally poll until terminal status.
3806
+
3807
+ Returns the create-response job dict, or the last GET payload when polling
3808
+ completes. Returns None for dry-run.
3809
+ """
3806
3810
  import time
3807
3811
  from urllib.parse import urlencode
3808
3812
 
@@ -3810,7 +3814,7 @@ def _queue_and_maybe_poll_remote_job(
3810
3814
 
3811
3815
  if dry_run:
3812
3816
  console.print(f"[cyan]Would POST[/cyan] {create_path}?" + urlencode(params))
3813
- return
3817
+ return None
3814
3818
  if poll and wait_timeout < 0:
3815
3819
  console.print(
3816
3820
  "[red]✗[/red] --wait-timeout must be >= 0 (0 means no limit while polling)."
@@ -3858,13 +3862,13 @@ def _queue_and_maybe_poll_remote_job(
3858
3862
  console.print(f"[green]✓[/green] {label} completed")
3859
3863
  if job.get("github_pr_url"):
3860
3864
  console.print(f" Pull request: {job['github_pr_url']}")
3861
- return
3865
+ return job
3862
3866
  if not poll:
3863
3867
  console.print(
3864
3868
  f"[dim]Not waiting: use [bold]--remote --poll[/bold] to stream progress "
3865
3869
  f"([bold]--wait-timeout 0[/bold] = no time limit while polling).[/dim]"
3866
3870
  )
3867
- return
3871
+ return job
3868
3872
  deadline = None if wait_timeout == 0 else time.monotonic() + wait_timeout
3869
3873
  if wait_timeout == 0:
3870
3874
  console.print("[dim]Polling until the job completes (no timeout).[/dim]")
@@ -3928,6 +3932,7 @@ def _queue_and_maybe_poll_remote_job(
3928
3932
  console.print(f"[green]✓[/green] {label} completed")
3929
3933
  if github_pr_url:
3930
3934
  console.print(f" Pull request: {github_pr_url}")
3935
+ return j2
3931
3936
  elif terminal == "failed":
3932
3937
  console.print(f"[red]✗[/red] {label} failed: {fail_message}")
3933
3938
  raise SystemExit(1)
@@ -5782,5 +5787,10 @@ def whoami_cmd():
5782
5787
  raise SystemExit(1)
5783
5788
 
5784
5789
 
5790
+ from chipfoundry_cli.preview import register_preview
5791
+
5792
+ register_preview(main)
5793
+
5794
+
5785
5795
  if __name__ == "__main__":
5786
5796
  main()
@@ -0,0 +1,920 @@
1
+ """`cf preview`: scan macros, classify LibreLane integration, optional lint+synth."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import re
7
+ from decimal import Decimal
8
+ from pathlib import Path
9
+ from typing import Any, Dict, List, Optional, Sequence, Set, Tuple
10
+
11
+ import click
12
+ from rich.console import Console
13
+ from rich.table import Table
14
+
15
+ from chipfoundry_cli.librelane_run import (
16
+ SYNTH_TO_STEP,
17
+ build_librelane_command,
18
+ detect_pdk,
19
+ find_config_file,
20
+ find_flow_root,
21
+ find_synth_stat_json,
22
+ librelane_venv,
23
+ list_macro_configs,
24
+ new_run_tag,
25
+ parse_synth_metrics,
26
+ pdk_root_dir,
27
+ resolve_execution_backend,
28
+ run_librelane,
29
+ )
30
+
31
+ console = Console()
32
+
33
+ STRATEGY_HIER_MACRO_FIRST = "hierarchical_macro_first"
34
+ STRATEGY_HIER_MACRO_FIRST_LEGACY = "hierarchical_macro_first_legacy"
35
+ STRATEGY_ANALOG_PG_WRAP = "analog_pg_wrap"
36
+ STRATEGY_FLATTENED_RTL = "flattened_rtl"
37
+ STRATEGY_HIER_RTL = "hierarchical_rtl"
38
+ STRATEGY_HARD_IP = "hard_ip"
39
+
40
+ LINT_PASSED = "passed"
41
+ LINT_FAILED = "failed"
42
+ LINT_SKIPPED = "skipped"
43
+
44
+ _ELABORATE_ONLY_STRATEGIES = {
45
+ STRATEGY_HIER_MACRO_FIRST,
46
+ STRATEGY_HIER_MACRO_FIRST_LEGACY,
47
+ STRATEGY_ANALOG_PG_WRAP,
48
+ STRATEGY_HARD_IP,
49
+ }
50
+
51
+ _LEGACY_BLACKBOX_SKIP = {"defines.v"}
52
+
53
+
54
+ def _truthy(value: Any) -> bool:
55
+ if value is True or value == 1:
56
+ return True
57
+ if isinstance(value, str) and value.strip().lower() in {"1", "true", "yes", "on"}:
58
+ return True
59
+ return False
60
+
61
+
62
+ def _parse_config_file(path: Path) -> Dict[str, Any]:
63
+ raw = path.read_text()
64
+ suffix = path.suffix.lower()
65
+ if suffix == ".json":
66
+ parsed = json.loads(raw)
67
+ elif suffix in {".yaml", ".yml"}:
68
+ try:
69
+ import yaml # type: ignore[import-untyped]
70
+ except ImportError as exc:
71
+ raise ValueError(
72
+ f"Cannot parse {path}: PyYAML is not installed"
73
+ ) from exc
74
+ parsed = yaml.safe_load(raw)
75
+ else:
76
+ raise ValueError(f"Unsupported config format for hierarchy scan: {path}")
77
+ if not isinstance(parsed, dict):
78
+ raise ValueError(f"Macro config {path} must contain an object at its root")
79
+ return parsed
80
+
81
+
82
+ def die_area_mm2(config: Dict[str, Any]) -> Optional[Decimal]:
83
+ """Convert LibreLane DIE_AREA (µm) to mm². Nested PDK maps are not walked."""
84
+ raw = config.get("DIE_AREA")
85
+ nums: List[float] = []
86
+ if isinstance(raw, (list, tuple)) and len(raw) >= 4:
87
+ try:
88
+ nums = [float(x) for x in raw[:4]]
89
+ except (TypeError, ValueError) as exc:
90
+ raise ValueError(f"Invalid DIE_AREA list: {raw}") from exc
91
+ elif isinstance(raw, str):
92
+ parts = raw.replace(",", " ").split()
93
+ if len(parts) >= 4:
94
+ try:
95
+ nums = [float(p) for p in parts[:4]]
96
+ except ValueError as exc:
97
+ raise ValueError(f"Invalid DIE_AREA string: {raw}") from exc
98
+ if len(nums) < 4:
99
+ return None
100
+ width = abs(nums[2] - nums[0])
101
+ height = abs(nums[3] - nums[1])
102
+ um2 = width * height
103
+ if um2 <= 0:
104
+ return None
105
+ return (Decimal(str(um2)) / Decimal("1000000")).quantize(Decimal("0.0001"))
106
+
107
+
108
+ def _stem_from_view_path(value: Any) -> Optional[str]:
109
+ if not isinstance(value, str) or not value.strip():
110
+ return None
111
+ name = Path(value.replace("dir::", "").strip()).name
112
+ if name in _LEGACY_BLACKBOX_SKIP:
113
+ return None
114
+ lower = name.lower()
115
+ for ext in (".gds.gz", ".lef", ".gds", ".v"):
116
+ if lower.endswith(ext):
117
+ return name[: -len(ext)]
118
+ stem = Path(name).stem
119
+ return stem or None
120
+
121
+
122
+ def _as_path_list(value: Any) -> List[str]:
123
+ if value is None:
124
+ return []
125
+ if isinstance(value, str):
126
+ return [value]
127
+ if isinstance(value, list):
128
+ return [item for item in value if isinstance(item, str)]
129
+ return []
130
+
131
+
132
+ def _macros_have_gds_lef(macros: Dict[str, Any]) -> bool:
133
+ for child in macros.values():
134
+ if not isinstance(child, dict):
135
+ continue
136
+ if _as_path_list(child.get("gds")) or _as_path_list(child.get("lef")):
137
+ return True
138
+ return False
139
+
140
+
141
+ def classify_integration_strategy(
142
+ config: Dict[str, Any],
143
+ *,
144
+ child_names_with_configs: Set[str],
145
+ ) -> str:
146
+ macros = config.get("MACROS")
147
+ has_macros = isinstance(macros, dict) and len(macros) > 0
148
+ elaborate = _truthy(config.get("SYNTH_ELABORATE_ONLY"))
149
+ has_pdn = bool(config.get("PDN_MACRO_CONNECTIONS"))
150
+ has_abstract = bool(config.get("MAGIC_EXT_ABSTRACT_CELLS"))
151
+ has_blackbox = bool(config.get("VERILOG_FILES_BLACKBOX"))
152
+ has_extra_lef = bool(config.get("EXTRA_LEFS"))
153
+ has_extra_gds = bool(config.get("EXTRA_GDS_FILES"))
154
+ has_placement = bool(config.get("MACRO_PLACEMENT_CFG"))
155
+ has_legacy_pdn = bool(config.get("FP_PDN_MACRO_HOOKS"))
156
+ legacy_hier = has_blackbox or has_extra_lef or has_extra_gds or has_placement or has_legacy_pdn
157
+
158
+ if elaborate and has_abstract and has_pdn:
159
+ return STRATEGY_ANALOG_PG_WRAP
160
+ if elaborate and has_macros and _macros_have_gds_lef(macros):
161
+ return STRATEGY_HIER_MACRO_FIRST
162
+ if elaborate and legacy_hier:
163
+ return STRATEGY_HIER_MACRO_FIRST_LEGACY
164
+ if has_macros:
165
+ child_keys = {str(k).strip() for k in macros.keys() if str(k).strip()}
166
+ if child_keys & child_names_with_configs and not _macros_have_gds_lef(macros):
167
+ return STRATEGY_HIER_RTL
168
+ if _macros_have_gds_lef(macros):
169
+ return STRATEGY_HIER_MACRO_FIRST
170
+ if child_keys & child_names_with_configs:
171
+ return STRATEGY_HIER_RTL
172
+ if legacy_hier and not elaborate:
173
+ return STRATEGY_HARD_IP
174
+ if has_macros:
175
+ return STRATEGY_HIER_RTL
176
+ return STRATEGY_FLATTENED_RTL
177
+
178
+
179
+ def _instance_count(child_config: Any) -> Optional[int]:
180
+ if not isinstance(child_config, dict):
181
+ return None
182
+ instances = child_config.get("instances")
183
+ if instances is None:
184
+ return 0
185
+ if isinstance(instances, dict):
186
+ return len(instances)
187
+ if isinstance(instances, list):
188
+ return len(instances)
189
+ raise ValueError("MACROS instances must be an object or array")
190
+
191
+
192
+ def _parse_placement_cfg(path: Path) -> int:
193
+ count = 0
194
+ for line in path.read_text().splitlines():
195
+ stripped = line.strip()
196
+ if not stripped or stripped.startswith("#"):
197
+ continue
198
+ count += 1
199
+ return count
200
+
201
+
202
+ def read_ipm_dependencies(project_root: Path) -> List[Dict[str, str]]:
203
+ """Read ``ip/dependencies.json`` (IPM). Empty if the file is absent."""
204
+ path = project_root / "ip" / "dependencies.json"
205
+ if not path.is_file():
206
+ return []
207
+ data = json.loads(path.read_text())
208
+ if not isinstance(data, dict):
209
+ raise ValueError(f"{path} must contain a JSON object")
210
+ entries = data.get("IP")
211
+ if entries is None:
212
+ return []
213
+ if not isinstance(entries, list):
214
+ raise ValueError(f"{path} field 'IP' must be an array")
215
+ out: List[Dict[str, str]] = []
216
+ for index, item in enumerate(entries):
217
+ if not isinstance(item, dict) or len(item) != 1:
218
+ raise ValueError(f"{path} IP[{index}] must be a single-key object")
219
+ name, version = next(iter(item.items()))
220
+ if not isinstance(name, str) or not name.strip():
221
+ raise ValueError(f"{path} IP[{index}] has an invalid name")
222
+ out.append({"name": name.strip(), "version": str(version), "source": "ipm"})
223
+ return out
224
+
225
+
226
+ def _legacy_child_names(config: Dict[str, Any]) -> List[str]:
227
+ names: List[str] = []
228
+ seen: Set[str] = set()
229
+ for key in ("EXTRA_LEFS", "EXTRA_GDS_FILES", "VERILOG_FILES_BLACKBOX"):
230
+ for raw in _as_path_list(config.get(key)):
231
+ stem = _stem_from_view_path(raw)
232
+ if stem and stem not in seen:
233
+ seen.add(stem)
234
+ names.append(stem)
235
+ return names
236
+
237
+
238
+ def scan_project(project_root: Path) -> Dict[str, Any]:
239
+ """Scan LibreLane configs, hierarchy, IPM, and integration strategy."""
240
+ notes: List[str] = []
241
+ discovered = list_macro_configs(project_root)
242
+ configs_by_dir_name = {row["name"]: row for row in discovered}
243
+ dir_names_with_configs = set(configs_by_dir_name)
244
+
245
+ macros_by_name: Dict[str, Dict[str, Any]] = {}
246
+ edges: Set[Tuple[str, str]] = set()
247
+ parsed_by_dir: Dict[str, Dict[str, Any]] = {}
248
+
249
+ for row in discovered:
250
+ path = project_root / row["config_path"]
251
+ if path.suffix.lower() == ".tcl":
252
+ notes.append(
253
+ f"Macro config {row['config_path']} is Tcl; hierarchy was not inferred from it"
254
+ )
255
+ parent = row["name"]
256
+ macros_by_name.setdefault(
257
+ parent,
258
+ {
259
+ "name": parent,
260
+ "notes": f"from {row['config_path']}",
261
+ "is_top_level": False,
262
+ "source": "cli_preview",
263
+ "config_path": row["config_path"],
264
+ "estimated_area_mm2": None,
265
+ "gate_count": None,
266
+ "instances": [],
267
+ "integration_strategy": None,
268
+ "lint_status": None,
269
+ },
270
+ )
271
+ continue
272
+ try:
273
+ config = _parse_config_file(path)
274
+ except (ValueError, json.JSONDecodeError) as exc:
275
+ notes.append(str(exc))
276
+ continue
277
+ parsed_by_dir[row["name"]] = config
278
+
279
+ design_name = config.get("DESIGN_NAME")
280
+ if not isinstance(design_name, str) or not design_name.strip():
281
+ notes.append(f"Macro config {row['config_path']} has no valid DESIGN_NAME")
282
+ parent = row["name"]
283
+ else:
284
+ parent = design_name.strip()
285
+
286
+ area = die_area_mm2(config)
287
+ parent_row = macros_by_name.setdefault(
288
+ parent,
289
+ {
290
+ "name": parent,
291
+ "notes": f"from {row['config_path']}",
292
+ "is_top_level": False,
293
+ "source": "cli_preview",
294
+ "config_path": row["config_path"],
295
+ "estimated_area_mm2": str(area) if area is not None else None,
296
+ "gate_count": None,
297
+ "instances": [],
298
+ "integration_strategy": None,
299
+ "lint_status": None,
300
+ },
301
+ )
302
+ if parent_row["config_path"] != row["config_path"]:
303
+ notes.append(
304
+ f"Multiple configs declare DESIGN_NAME {parent!r}; using {parent_row['config_path']}"
305
+ )
306
+ elif area is not None and parent_row["estimated_area_mm2"] is None:
307
+ parent_row["estimated_area_mm2"] = str(area)
308
+
309
+ macro_section = config.get("MACROS")
310
+ if isinstance(macro_section, dict):
311
+ for child_name, child_config in macro_section.items():
312
+ if not isinstance(child_name, str) or not child_name.strip():
313
+ notes.append(f"Macro config {row['config_path']} contains an invalid MACROS key")
314
+ continue
315
+ child = child_name.strip()
316
+ try:
317
+ count = _instance_count(child_config)
318
+ except ValueError as exc:
319
+ notes.append(f"Macro {child!r} in {row['config_path']}: {exc}")
320
+ continue
321
+ if count is None:
322
+ notes.append(f"Macro {child!r} in {row['config_path']} is not an object")
323
+ continue
324
+ child_row = macros_by_name.setdefault(
325
+ child,
326
+ {
327
+ "name": child,
328
+ "notes": None,
329
+ "is_top_level": False,
330
+ "source": "cli_preview",
331
+ "config_path": None,
332
+ "estimated_area_mm2": None,
333
+ "gate_count": None,
334
+ "instances": [],
335
+ "integration_strategy": STRATEGY_HARD_IP,
336
+ "lint_status": None,
337
+ },
338
+ )
339
+ if count > 0:
340
+ child_row["instances"].append({"parent_macro": parent, "count": count})
341
+ edges.add((parent, child))
342
+ elif macro_section is not None:
343
+ notes.append(f"Macro config {row['config_path']} has MACROS that is not an object")
344
+
345
+ placement_count = None
346
+ placement = config.get("MACRO_PLACEMENT_CFG")
347
+ if isinstance(placement, str) and placement.strip():
348
+ rel = placement.replace("dir::", "")
349
+ placement_path = (path.parent / rel).resolve()
350
+ if placement_path.is_file():
351
+ placement_count = _parse_placement_cfg(placement_path)
352
+ else:
353
+ notes.append(f"MACRO_PLACEMENT_CFG not found for {row['config_path']}: {placement}")
354
+
355
+ for child in _legacy_child_names(config):
356
+ child_row = macros_by_name.setdefault(
357
+ child,
358
+ {
359
+ "name": child,
360
+ "notes": None,
361
+ "is_top_level": False,
362
+ "source": "cli_preview",
363
+ "config_path": None,
364
+ "estimated_area_mm2": None,
365
+ "gate_count": None,
366
+ "instances": [],
367
+ "integration_strategy": STRATEGY_HARD_IP,
368
+ "lint_status": None,
369
+ },
370
+ )
371
+ count = placement_count if placement_count and placement_count > 0 else 1
372
+ if not any(inst["parent_macro"] == parent for inst in child_row["instances"]):
373
+ child_row["instances"].append({"parent_macro": parent, "count": count})
374
+ edges.add((parent, child))
375
+
376
+ child_config_names: Set[str] = set()
377
+ for name in macros_by_name:
378
+ if name in dir_names_with_configs:
379
+ child_config_names.add(name)
380
+ # DESIGN_NAME may differ from directory name
381
+ for dir_name, config in parsed_by_dir.items():
382
+ design = config.get("DESIGN_NAME")
383
+ if isinstance(design, str) and design.strip() == name:
384
+ child_config_names.add(name)
385
+
386
+ for dir_name, config in parsed_by_dir.items():
387
+ design = config.get("DESIGN_NAME")
388
+ parent = design.strip() if isinstance(design, str) and design.strip() else dir_name
389
+ strategy = classify_integration_strategy(
390
+ config, child_names_with_configs=child_config_names
391
+ )
392
+ if parent in macros_by_name:
393
+ macros_by_name[parent]["integration_strategy"] = strategy
394
+
395
+ if macros_by_name:
396
+ children = {child for _, child in edges}
397
+ roots = sorted(set(macros_by_name) - children)
398
+ if len(roots) == 1:
399
+ macros_by_name[roots[0]]["is_top_level"] = True
400
+ notes.append(f"Identified unique design hierarchy root: {roots[0]}")
401
+ elif len(roots) > 1:
402
+ notes.append(
403
+ "Multiple design hierarchy roots found; no macro was marked top-level: "
404
+ + ", ".join(roots)
405
+ )
406
+ else:
407
+ notes.append("No design hierarchy root found; the macro graph may contain a cycle")
408
+
409
+ ipm = read_ipm_dependencies(project_root)
410
+ marketplace_ip: List[Dict[str, str]] = list(ipm)
411
+ seen_ip = {item["name"] for item in marketplace_ip}
412
+ for name, row in macros_by_name.items():
413
+ if row.get("integration_strategy") == STRATEGY_HARD_IP and name not in seen_ip:
414
+ marketplace_ip.append({"name": name, "version": "", "source": "config"})
415
+ seen_ip.add(name)
416
+ elif name.startswith("CF_") and name not in seen_ip:
417
+ marketplace_ip.append({"name": name, "version": "", "source": "config"})
418
+ seen_ip.add(name)
419
+
420
+ if discovered:
421
+ notes.append(f"Found {len(discovered)} OpenLane/LibreLane macro config(s)")
422
+ if ipm:
423
+ notes.append(f"Found {len(ipm)} IPM dependenc(ies) in ip/dependencies.json")
424
+
425
+ macros = [macros_by_name[name] for name in sorted(macros_by_name)]
426
+ return {
427
+ "macros": macros,
428
+ "marketplace_ip": marketplace_ip,
429
+ "notes": notes,
430
+ "discovered": discovered,
431
+ "parsed_by_dir": {k: True for k in parsed_by_dir},
432
+ }
433
+
434
+
435
+ def strategy_uses_gate_count(strategy: Optional[str]) -> bool:
436
+ return strategy == STRATEGY_FLATTENED_RTL or strategy == STRATEGY_HIER_RTL
437
+
438
+
439
+ def find_verilator_lint_errors(flow_root: Path, macro: str, run_tag: str) -> Optional[int]:
440
+ run_dir = flow_root / macro / "runs" / run_tag
441
+ if not run_dir.is_dir():
442
+ return None
443
+ for path in run_dir.rglob("*"):
444
+ if not path.is_file():
445
+ continue
446
+ name = path.name.lower()
447
+ if "lint" in name and path.suffix in {".rpt", ".log", ".txt"}:
448
+ text = path.read_text(errors="replace")
449
+ errors = len(re.findall(r"\berror\b", text, flags=re.IGNORECASE))
450
+ return errors
451
+ metrics = run_dir / "final" / "metrics.json"
452
+ if metrics.is_file():
453
+ data = json.loads(metrics.read_text())
454
+ if isinstance(data, dict) and "verilator__lint_error__count" in data:
455
+ return int(float(data["verilator__lint_error__count"]))
456
+ return 0
457
+
458
+
459
+ def apply_synth_metrics(
460
+ macro: Dict[str, Any],
461
+ metrics: Dict[str, Any],
462
+ *,
463
+ lint_errors: Optional[int],
464
+ ) -> None:
465
+ strategy = macro.get("integration_strategy")
466
+ if lint_errors is None:
467
+ macro["lint_status"] = LINT_SKIPPED
468
+ elif lint_errors == 0:
469
+ macro["lint_status"] = LINT_PASSED
470
+ else:
471
+ macro["lint_status"] = LINT_FAILED
472
+ if strategy_uses_gate_count(strategy):
473
+ macro["gate_count"] = metrics["gate_count"]
474
+ area = metrics.get("estimated_area_mm2")
475
+ if area is not None:
476
+ macro["estimated_area_mm2"] = str(area)
477
+ elif macro.get("estimated_area_mm2") is None and metrics.get("estimated_area_mm2") is not None:
478
+ # Keep DIE_AREA / catalog area; do not treat Yosys cells as gates.
479
+ pass
480
+
481
+
482
+ def leaf_summary(macros: Sequence[Dict[str, Any]]) -> Dict[str, Any]:
483
+ parent_names = {
484
+ inst["parent_macro"]
485
+ for row in macros
486
+ for inst in row.get("instances") or []
487
+ }
488
+ known_area = Decimal("0")
489
+ known_gates = 0
490
+ missing_area = 0
491
+ missing_gates = 0
492
+ instantiated = 0
493
+ leaf_count = 0
494
+ for row in macros:
495
+ is_leaf = (not row.get("is_top_level")) and row["name"] not in parent_names
496
+ if not is_leaf:
497
+ continue
498
+ leaf_count += 1
499
+ instance_count = sum(int(inst["count"]) for inst in (row.get("instances") or []))
500
+ if instance_count == 0:
501
+ continue
502
+ instantiated += 1
503
+ area = row.get("estimated_area_mm2")
504
+ if area in (None, ""):
505
+ missing_area += 1
506
+ else:
507
+ known_area += Decimal(str(area)) * instance_count
508
+ gates = row.get("gate_count")
509
+ if gates is None:
510
+ missing_gates += 1
511
+ else:
512
+ known_gates += int(gates) * instance_count
513
+ total_area = known_area if instantiated > 0 and missing_area == 0 else None
514
+ total_gates = known_gates if instantiated > 0 and missing_gates == 0 else None
515
+ return {
516
+ "leaf_macro_count": leaf_count,
517
+ "instantiated_leaf_macro_count": instantiated,
518
+ "known_area_mm2": str(known_area),
519
+ "total_area_mm2": str(total_area) if total_area is not None else None,
520
+ "missing_area_macro_count": missing_area,
521
+ "known_gate_count": known_gates,
522
+ "total_gate_count": total_gates,
523
+ "missing_gate_count_macro_count": missing_gates,
524
+ }
525
+
526
+
527
+ def print_preview_report(scan: Dict[str, Any], summary: Dict[str, Any]) -> None:
528
+ table = Table(title="Project preview", show_lines=False)
529
+ table.add_column("Macro", style="cyan")
530
+ table.add_column("Strategy")
531
+ table.add_column("Hierarchy")
532
+ table.add_column("Gates", justify="right")
533
+ table.add_column("Area mm²", justify="right")
534
+ table.add_column("Lint")
535
+ for row in scan["macros"]:
536
+ if row.get("is_top_level"):
537
+ hier = "Top level"
538
+ elif row.get("instances"):
539
+ hier = ", ".join(
540
+ f"{inst['count']}× by {inst['parent_macro']}"
541
+ for inst in row["instances"]
542
+ )
543
+ else:
544
+ hier = "Not instantiated"
545
+ gates = "" if row.get("gate_count") is None else f"{row['gate_count']:,}"
546
+ area = row.get("estimated_area_mm2") or "—"
547
+ table.add_row(
548
+ row["name"],
549
+ row.get("integration_strategy") or "—",
550
+ hier,
551
+ gates or "—",
552
+ str(area),
553
+ row.get("lint_status") or "—",
554
+ )
555
+ console.print(table)
556
+
557
+ ip_table = Table(title="Marketplace / IPM IP", show_lines=False)
558
+ ip_table.add_column("Name", style="cyan")
559
+ ip_table.add_column("Version")
560
+ ip_table.add_column("Detected from")
561
+ if scan["marketplace_ip"]:
562
+ for item in scan["marketplace_ip"]:
563
+ ip_table.add_row(item["name"], item.get("version") or "—", item.get("source") or "—")
564
+ console.print(ip_table)
565
+ else:
566
+ console.print("[yellow]No marketplace IP or IPM dependencies detected[/yellow]")
567
+
568
+ console.print(
569
+ f"[bold]Leaf macros:[/bold] {summary['leaf_macro_count']} "
570
+ f"instantiated {summary['instantiated_leaf_macro_count']} "
571
+ f"known gates {summary['known_gate_count']:,} "
572
+ f"known area {summary['known_area_mm2']} mm²"
573
+ )
574
+ for note in scan.get("notes") or []:
575
+ console.print(f"[dim]• {note}[/dim]")
576
+
577
+
578
+ def run_local_synth_for_macros(
579
+ project_root: Path,
580
+ scan: Dict[str, Any],
581
+ *,
582
+ only_macro: Optional[str],
583
+ pdk: Optional[str],
584
+ use_nix: bool,
585
+ use_docker: bool,
586
+ dry_run: bool,
587
+ ) -> None:
588
+ from chipfoundry_cli.utils import fetch_versions_from_upstream
589
+
590
+ flow_root = find_flow_root(project_root)
591
+ if flow_root is None:
592
+ raise click.ClickException("No openlane/ or librelane/ directory found")
593
+ venv = librelane_venv(flow_root)
594
+ if not venv.exists():
595
+ raise click.ClickException(
596
+ "LibreLane not installed. Run 'cf setup --only-openlane' or pass --skip-synth."
597
+ )
598
+ pdk_name = detect_pdk(project_root, pdk)
599
+ pdk_root = pdk_root_dir(project_root)
600
+ if not (pdk_root / pdk_name).exists():
601
+ raise click.ClickException(
602
+ f"PDK not found: {pdk_root / pdk_name}. Run 'cf setup --only-pdk' or pass --skip-synth."
603
+ )
604
+ versions = fetch_versions_from_upstream("chipfoundry", "cf-cli", "main")
605
+ openlane_version = versions["openlane_version"]
606
+ backend, err = resolve_execution_backend(
607
+ openlane_version=openlane_version,
608
+ force_nix=use_nix,
609
+ force_docker=use_docker,
610
+ )
611
+ if err:
612
+ raise click.ClickException(err)
613
+
614
+ targets: List[Dict[str, Any]] = []
615
+ for macro in scan["macros"]:
616
+ if only_macro and macro["name"] != only_macro:
617
+ continue
618
+ if not macro.get("config_path"):
619
+ continue
620
+ if not strategy_uses_gate_count(macro.get("integration_strategy")):
621
+ macro["lint_status"] = macro.get("lint_status") or LINT_SKIPPED
622
+ continue
623
+ targets.append(macro)
624
+
625
+ if only_macro and not any(m["name"] == only_macro for m in scan["macros"]):
626
+ raise click.ClickException(f"Macro not found: {only_macro}")
627
+
628
+ for macro in targets:
629
+ config_rel = macro["config_path"]
630
+ config_file = project_root / config_rel
631
+ dir_name = Path(config_rel).parent.name
632
+ tag = new_run_tag()
633
+ console.print(f"[cyan]Lint + synthesis:[/cyan] {macro['name']} → {SYNTH_TO_STEP} (tag {tag})")
634
+ cmd, env = build_librelane_command(
635
+ project_root=project_root,
636
+ flow_root=flow_root,
637
+ config_file=config_file,
638
+ pdk=pdk_name,
639
+ pdk_root=pdk_root,
640
+ tag=tag,
641
+ openlane_version=openlane_version,
642
+ backend=backend,
643
+ to_step=SYNTH_TO_STEP,
644
+ overwrite=True,
645
+ )
646
+ if dry_run:
647
+ console.print("[dim]" + " ".join(cmd) + "[/dim]")
648
+ continue
649
+ code = run_librelane(cmd, cwd=flow_root, env=env)
650
+ if code != 0:
651
+ raise click.ClickException(f"LibreLane preview failed for {macro['name']} (exit {code})")
652
+ stat_path = find_synth_stat_json(flow_root, dir_name, tag)
653
+ if stat_path is None:
654
+ raise click.ClickException(
655
+ f"Synthesizable macro {macro['name']} finished with no stat.json"
656
+ )
657
+ metrics = parse_synth_metrics(stat_path)
658
+ lint_errors = find_verilator_lint_errors(flow_root, dir_name, tag)
659
+ apply_synth_metrics(macro, metrics, lint_errors=lint_errors)
660
+
661
+
662
+ def build_preview_payload(
663
+ scan: Dict[str, Any],
664
+ *,
665
+ synth_mode: str,
666
+ git_sha: Optional[str],
667
+ cli_version: str,
668
+ ) -> Dict[str, Any]:
669
+ macros = []
670
+ for row in scan["macros"]:
671
+ item = {
672
+ "name": row["name"],
673
+ "notes": row.get("notes"),
674
+ "is_top_level": bool(row.get("is_top_level")),
675
+ "source": "cli_preview",
676
+ "config_path": row.get("config_path"),
677
+ "estimated_area_mm2": row.get("estimated_area_mm2"),
678
+ "gate_count": row.get("gate_count"),
679
+ "instances": row.get("instances") or [],
680
+ "integration_strategy": row.get("integration_strategy"),
681
+ "lint_status": row.get("lint_status"),
682
+ }
683
+ macros.append(item)
684
+ return {
685
+ "macros": macros,
686
+ "marketplace_ip": scan.get("marketplace_ip") or [],
687
+ "notes": scan.get("notes") or [],
688
+ "git_sha": git_sha,
689
+ "cli_version": cli_version,
690
+ "synth_mode": synth_mode,
691
+ "summary": leaf_summary(scan["macros"]),
692
+ }
693
+
694
+
695
+ def merge_remote_job_metrics(scan: Dict[str, Any], job: Dict[str, Any]) -> None:
696
+ """Apply pnr_results stats from a completed preview job onto the matching macro."""
697
+ macro_name = job.get("macro")
698
+ results = job.get("pnr_results") or {}
699
+ stats = results.get("stats") if isinstance(results, dict) else None
700
+ if not macro_name or not isinstance(stats, dict):
701
+ return
702
+ gate_count = stats.get("instance_count") or stats.get("design__instance__count")
703
+ area = stats.get("instance_area") or stats.get("design__instance__area")
704
+ metrics: Dict[str, Any] = {}
705
+ if gate_count is not None:
706
+ metrics["gate_count"] = int(float(gate_count))
707
+ if area not in (None, "", 0, 0.0):
708
+ # LibreLane instance area is µm².
709
+ metrics["estimated_area_mm2"] = float(area) / 1_000_000.0
710
+ if "gate_count" not in metrics:
711
+ return
712
+ for macro in scan["macros"]:
713
+ if macro["name"] != macro_name:
714
+ continue
715
+ apply_synth_metrics(macro, metrics, lint_errors=0)
716
+ return
717
+
718
+
719
+ def register_preview(main_group) -> None:
720
+ @main_group.command("preview")
721
+ @click.option(
722
+ "--project-root",
723
+ type=click.Path(exists=True, file_okay=False),
724
+ help="Path to the project directory (defaults to current directory)",
725
+ )
726
+ @click.option("--macro", help="Only run lint+synth for this macro (scan still covers the project)")
727
+ @click.option("--skip-synth", is_flag=True, help="Scan and classify only; do not run LibreLane")
728
+ @click.option("--no-push", is_flag=True, help="Print the report without posting to the platform")
729
+ @click.option("--pdk", help="PDK to use (defaults to project.json or sky130A)")
730
+ @click.option("--use-nix", is_flag=True, help="Force Nix for local lint+synth")
731
+ @click.option("--use-docker", is_flag=True, help="Force Docker for local lint+synth")
732
+ @click.option("--dry-run", is_flag=True, help="Show LibreLane commands without running them")
733
+ @click.option("--remote", is_flag=True, help="Queue synth-only PNR jobs on the platform")
734
+ @click.option("--poll", is_flag=True, help="With --remote: wait until jobs finish")
735
+ @click.option("--git-ref", default="main", show_default=True, help="Git ref for remote preview jobs")
736
+ @click.option(
737
+ "--wait-timeout",
738
+ type=int,
739
+ default=7200,
740
+ show_default=True,
741
+ help="With --remote --poll: max seconds to wait (0 = no limit)",
742
+ )
743
+ def preview(
744
+ project_root,
745
+ macro,
746
+ skip_synth,
747
+ no_push,
748
+ pdk,
749
+ use_nix,
750
+ use_docker,
751
+ dry_run,
752
+ remote,
753
+ poll,
754
+ git_ref,
755
+ wait_timeout,
756
+ ):
757
+ """Scan macros, IP, and LibreLane strategy; optionally lint+synthesize.
758
+
759
+ Examples:
760
+ cf preview
761
+ cf preview --skip-synth
762
+ cf preview --macro user_proj_example
763
+ cf preview --remote --poll
764
+ cf preview --no-push
765
+ """
766
+ from chipfoundry_cli.main import (
767
+ _api_post,
768
+ _load_project_platform_id,
769
+ _queue_and_maybe_poll_remote_job,
770
+ check_project_initialized,
771
+ get_project_json_from_cwd,
772
+ )
773
+ from chipfoundry_cli.utils import get_head_commit_sha
774
+ import importlib.metadata
775
+
776
+ cwd_root, _ = get_project_json_from_cwd()
777
+ if not project_root and cwd_root:
778
+ project_root = cwd_root
779
+ if not project_root:
780
+ project_root = os_getcwd()
781
+ project_root_path = Path(project_root)
782
+
783
+ if poll and not remote:
784
+ console.print("[red]✗[/red] --poll requires --remote.")
785
+ raise SystemExit(1)
786
+ if remote and skip_synth:
787
+ console.print("[red]✗[/red] --remote cannot be combined with --skip-synth.")
788
+ raise SystemExit(1)
789
+ if remote and (use_nix or use_docker):
790
+ console.print("[red]✗[/red] --remote cannot be combined with --use-nix or --use-docker.")
791
+ raise SystemExit(1)
792
+
793
+ if not check_project_initialized(
794
+ project_root_path, "preview", dry_run=dry_run, allow_graceful=True
795
+ ):
796
+ console.print("[red]✗[/red] Project not initialized. Please run 'cf init' first.")
797
+ return
798
+
799
+ scan = scan_project(project_root_path)
800
+ if not scan["macros"] and not scan["marketplace_ip"]:
801
+ console.print("[yellow]No LibreLane macros or IPM dependencies found[/yellow]")
802
+
803
+ synth_mode = "skipped"
804
+ if skip_synth:
805
+ for row in scan["macros"]:
806
+ row["lint_status"] = LINT_SKIPPED
807
+ elif remote:
808
+ synth_mode = "remote"
809
+ else:
810
+ synth_mode = "local"
811
+ if not dry_run:
812
+ run_local_synth_for_macros(
813
+ project_root_path,
814
+ scan,
815
+ only_macro=macro,
816
+ pdk=pdk,
817
+ use_nix=use_nix,
818
+ use_docker=use_docker,
819
+ dry_run=False,
820
+ )
821
+ else:
822
+ run_local_synth_for_macros(
823
+ project_root_path,
824
+ scan,
825
+ only_macro=macro,
826
+ pdk=pdk,
827
+ use_nix=use_nix,
828
+ use_docker=use_docker,
829
+ dry_run=True,
830
+ )
831
+
832
+ summary = leaf_summary(scan["macros"])
833
+ print_preview_report(scan, summary)
834
+
835
+ git_sha = None
836
+ try:
837
+ git_sha = get_head_commit_sha(str(project_root_path))
838
+ except Exception:
839
+ git_sha = None
840
+ cli_version = importlib.metadata.version("chipfoundry-cli")
841
+ payload = build_preview_payload(
842
+ scan,
843
+ synth_mode=synth_mode,
844
+ git_sha=git_sha,
845
+ cli_version=cli_version,
846
+ )
847
+
848
+ platform_id = _load_project_platform_id(str(project_root_path))
849
+ if remote:
850
+ if not platform_id:
851
+ console.print(
852
+ "[red]✗[/red] Link this repo to a platform project (set platform_project_id via [bold]cf link[/bold])."
853
+ )
854
+ raise SystemExit(1)
855
+ if not no_push:
856
+ _api_post(f"/projects/{platform_id}/preview", payload)
857
+ console.print("[green]✓ Preview scan synced to platform[/green]")
858
+ targets = [
859
+ row
860
+ for row in scan["macros"]
861
+ if row.get("config_path") and strategy_uses_gate_count(row.get("integration_strategy"))
862
+ ]
863
+ if macro:
864
+ targets = [row for row in targets if row["name"] == macro]
865
+ from chipfoundry_cli.remote_precheck_git import (
866
+ RemotePrecheckGitError,
867
+ verify_remote_job_repo,
868
+ )
869
+
870
+ try:
871
+ verify_remote_job_repo(project_root_path, git_ref)
872
+ except RemotePrecheckGitError as e:
873
+ console.print(f"[red]✗[/red] {e}")
874
+ raise SystemExit(1)
875
+ for row in targets:
876
+ params = [
877
+ ("macro", row["name"]),
878
+ ("git_ref", git_ref),
879
+ ("purpose", "preview"),
880
+ ("to_step", SYNTH_TO_STEP),
881
+ ]
882
+ if pdk:
883
+ params.append(("pdk", pdk))
884
+ job = _queue_and_maybe_poll_remote_job(
885
+ create_path=f"/projects/{platform_id}/pnr-jobs",
886
+ job_get_path_template=f"/projects/{platform_id}/pnr-jobs/{{jid}}",
887
+ params=params,
888
+ dry_run=dry_run,
889
+ poll=poll,
890
+ wait_timeout=wait_timeout,
891
+ label=f"Remote preview ({row['name']})",
892
+ )
893
+ if poll and isinstance(job, dict):
894
+ merge_remote_job_metrics(scan, job)
895
+ if poll and not no_push and not dry_run:
896
+ payload = build_preview_payload(
897
+ scan,
898
+ synth_mode="remote",
899
+ git_sha=git_sha,
900
+ cli_version=cli_version,
901
+ )
902
+ _api_post(f"/projects/{platform_id}/preview", payload)
903
+ console.print("[green]✓ Preview synthesis metrics synced to platform[/green]")
904
+ return
905
+
906
+ if no_push or dry_run:
907
+ return
908
+ if not platform_id:
909
+ console.print(
910
+ "[yellow]⚠ Preview not synced: link this repo with [bold]cf link[/bold][/yellow]"
911
+ )
912
+ return
913
+ _api_post(f"/projects/{platform_id}/preview", payload)
914
+ console.print("[green]✓ Preview results synced to platform[/green]")
915
+
916
+
917
+ def os_getcwd() -> str:
918
+ import os
919
+
920
+ return os.getcwd()
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "chipfoundry-cli"
3
- version = "2.5.4"
3
+ version = "2.6.0"
4
4
  description = "CLI tool to automate ChipFoundry project submission to SFTP server"
5
5
  authors = ["ChipFoundry <marwan.abbas@chipfoundry.io>"]
6
6
  readme = "README.md"
File without changes