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.
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/PKG-INFO +1 -1
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/__init__.py +1 -1
- chipfoundry_cli-2.6.0/chipfoundry_cli/librelane_run.py +294 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/main.py +15 -5
- chipfoundry_cli-2.6.0/chipfoundry_cli/preview.py +920 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/pyproject.toml +1 -1
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/LICENSE +0 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/README.md +0 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/check_refs.py +0 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/remote_precheck_git.py +0 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/utils.py +0 -0
- {chipfoundry_cli-2.5.4 → chipfoundry_cli-2.6.0}/chipfoundry_cli/version_check.py +0 -0
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""ChipFoundry CLI package: Automate project submission to SFTP."""
|
|
2
|
-
__version__ = "2.
|
|
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
|
-
)
|
|
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()
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|