Skip to content

Commit ba2425b

Browse files
committed
Prepare Sky Flow beta 9 release
1 parent ab7dbc7 commit ba2425b

12 files changed

Lines changed: 1678 additions & 34 deletions

app/core/repo_readiness.py

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,256 @@
1+
"""Local repository readiness checks for Tiny Tapeout-style submissions."""
2+
3+
from __future__ import annotations
4+
5+
from dataclasses import dataclass
6+
from pathlib import Path
7+
import re
8+
9+
10+
@dataclass(frozen=True)
11+
class ReadinessCheck:
12+
"""A single local submission-readiness check."""
13+
14+
title: str
15+
status: str
16+
detail: str
17+
path: Path | None = None
18+
19+
20+
class RepoReadinessChecker:
21+
"""Validate a project tree before committing or pushing to GitHub."""
22+
23+
def check(self, project_root: str | Path) -> list[ReadinessCheck]:
24+
root = Path(project_root).expanduser().resolve()
25+
checks: list[ReadinessCheck] = []
26+
checks.append(self._check_root(root))
27+
checks.extend(self._check_template_files(root))
28+
info = self._read_info_yaml(root)
29+
checks.extend(self._check_info_yaml(root, info))
30+
checks.extend(self._check_submission_artifacts(root, info))
31+
checks.extend(self._check_outputs(root))
32+
checks.extend(self._check_git_hygiene(root))
33+
return checks
34+
35+
def _check_root(self, root: Path) -> ReadinessCheck:
36+
if root.is_dir():
37+
return ReadinessCheck("Proyecto", "ok", f"Raiz encontrada: {root}", root)
38+
return ReadinessCheck("Proyecto", "error", f"No existe la carpeta del proyecto: {root}", root)
39+
40+
def _check_template_files(self, root: Path) -> list[ReadinessCheck]:
41+
required = [
42+
("README.md", "README"),
43+
("info.yaml", "Metadata Tiny Tapeout"),
44+
("docs/info.md", "Documentacion publica"),
45+
("src/project.v", "Wrapper HDL del template"),
46+
("test", "Carpeta de tests"),
47+
]
48+
checks = []
49+
for relative, title in required:
50+
path = root / relative
51+
if path.exists():
52+
checks.append(ReadinessCheck(title, "ok", f"Existe {relative}", path))
53+
else:
54+
checks.append(ReadinessCheck(title, "error", f"Falta {relative}", path))
55+
return checks
56+
57+
def _read_info_yaml(self, root: Path) -> dict[str, object]:
58+
path = root / "info.yaml"
59+
if not path.is_file():
60+
return {}
61+
try:
62+
return self._parse_info_yaml(path.read_text())
63+
except OSError:
64+
return {}
65+
66+
def _parse_info_yaml(self, text: str) -> dict[str, object]:
67+
data: dict[str, object] = {"source_files": []}
68+
section = ""
69+
in_source_files = False
70+
for raw_line in text.splitlines():
71+
line = raw_line.split("#", 1)[0].rstrip()
72+
if not line.strip():
73+
continue
74+
stripped = line.strip()
75+
if not raw_line.startswith(" ") and stripped.endswith(":"):
76+
section = stripped[:-1]
77+
in_source_files = False
78+
continue
79+
if not raw_line.startswith(" ") and ":" in stripped:
80+
key, value = stripped.split(":", 1)
81+
data[key.strip()] = self._clean_yaml_scalar(value)
82+
section = ""
83+
in_source_files = False
84+
continue
85+
if section == "project" and stripped == "source_files:":
86+
in_source_files = True
87+
continue
88+
if section == "project" and in_source_files and stripped.startswith("- "):
89+
source_files = data.setdefault("source_files", [])
90+
if isinstance(source_files, list):
91+
source_files.append(self._clean_yaml_scalar(stripped[2:]))
92+
continue
93+
if section == "project" and ":" in stripped:
94+
key, value = stripped.split(":", 1)
95+
data[f"project.{key.strip()}"] = self._clean_yaml_scalar(value)
96+
in_source_files = False
97+
return data
98+
99+
@staticmethod
100+
def _clean_yaml_scalar(value: str) -> str:
101+
cleaned = value.strip()
102+
if len(cleaned) >= 2 and cleaned[0] == cleaned[-1] and cleaned[0] in {"'", '"'}:
103+
return cleaned[1:-1]
104+
return cleaned
105+
106+
def _check_info_yaml(self, root: Path, info: dict[str, object]) -> list[ReadinessCheck]:
107+
path = root / "info.yaml"
108+
if not path.is_file():
109+
return [ReadinessCheck("info.yaml", "error", "Falta info.yaml", path)]
110+
111+
required_fields = [
112+
"yaml_version",
113+
"project.title",
114+
"project.author",
115+
"project.description",
116+
"project.top_module",
117+
"project.source_files",
118+
"project.tiles",
119+
"project.analog_pins",
120+
"project.uses_3v3",
121+
]
122+
checks: list[ReadinessCheck] = []
123+
for field in required_fields:
124+
value = info.get(field)
125+
if field == "project.source_files":
126+
value = info.get("source_files")
127+
if value:
128+
checks.append(ReadinessCheck(f"info.yaml:{field}", "ok", f"{field} definido", path))
129+
else:
130+
checks.append(ReadinessCheck(f"info.yaml:{field}", "error", f"Falta {field}", path))
131+
132+
yaml_version = str(info.get("yaml_version", ""))
133+
if yaml_version == "6":
134+
checks.append(ReadinessCheck("info.yaml version", "ok", "yaml_version es 6", path))
135+
else:
136+
checks.append(ReadinessCheck("info.yaml version", "warning", f"yaml_version esperado: 6; actual: {yaml_version or '-'}", path))
137+
138+
top_module = str(info.get("project.top_module", ""))
139+
if top_module.startswith("tt_um_"):
140+
checks.append(ReadinessCheck("Top module", "ok", f"Top module Tiny Tapeout: {top_module}", path))
141+
else:
142+
checks.append(ReadinessCheck("Top module", "warning", f"El top_module no empieza con tt_um_: {top_module or '-'}", path))
143+
144+
tiles = str(info.get("project.tiles", ""))
145+
if tiles in {"1x2", "2x2"}:
146+
checks.append(ReadinessCheck("Tiles", "ok", f"Tiles valido: {tiles}", path))
147+
else:
148+
checks.append(ReadinessCheck("Tiles", "warning", f"Tiles esperado 1x2 o 2x2; actual: {tiles or '-'}", path))
149+
150+
analog_pins = str(info.get("project.analog_pins", ""))
151+
if analog_pins.isdigit() and 0 <= int(analog_pins) <= 6:
152+
checks.append(ReadinessCheck("Analog pins", "ok", f"analog_pins={analog_pins}", path))
153+
else:
154+
checks.append(ReadinessCheck("Analog pins", "error", f"analog_pins debe estar entre 0 y 6; actual: {analog_pins or '-'}", path))
155+
156+
return checks
157+
158+
def _check_submission_artifacts(self, root: Path, info: dict[str, object]) -> list[ReadinessCheck]:
159+
checks: list[ReadinessCheck] = []
160+
source_files = info.get("source_files", [])
161+
if isinstance(source_files, list) and source_files:
162+
for source_file in source_files:
163+
source_path = root / "src" / str(source_file)
164+
if source_path.is_file():
165+
checks.append(ReadinessCheck("Source file", "ok", f"Existe src/{source_file}", source_path))
166+
else:
167+
checks.append(ReadinessCheck("Source file", "error", f"Falta src/{source_file}", source_path))
168+
else:
169+
checks.append(ReadinessCheck("Source files", "error", "info.yaml no lista source_files", root / "info.yaml"))
170+
171+
gds_files = sorted((root / "gds").glob("*.gds")) if (root / "gds").is_dir() else []
172+
if gds_files:
173+
checks.append(ReadinessCheck("GDS", "ok", f"GDS encontrado: {gds_files[0].name}", gds_files[0]))
174+
else:
175+
checks.append(ReadinessCheck("GDS", "warning", "No hay GDS en gds/. Para analog/custom normalmente debe existir antes de submission.", root / "gds"))
176+
177+
lef_files = sorted((root / "lef").glob("*.lef")) if (root / "lef").is_dir() else []
178+
if lef_files:
179+
checks.append(ReadinessCheck("LEF", "ok", f"LEF encontrado: {lef_files[0].name}", lef_files[0]))
180+
else:
181+
checks.append(ReadinessCheck("LEF", "warning", "No hay LEF en lef/. Puede ser necesario para el flujo final.", root / "lef"))
182+
return checks
183+
184+
def _check_outputs(self, root: Path) -> list[ReadinessCheck]:
185+
checks: list[ReadinessCheck] = []
186+
extraction = root / "runs" / "extraction"
187+
extracted = sorted(extraction.glob("*_extracted.spice")) if extraction.is_dir() else []
188+
checks.append(
189+
ReadinessCheck(
190+
"Extraccion Magic",
191+
"ok" if extracted else "warning",
192+
f"Netlist extraido: {extracted[-1].name}" if extracted else "No se encontro *_extracted.spice en runs/extraction.",
193+
extracted[-1] if extracted else extraction,
194+
)
195+
)
196+
197+
checks.append(self._check_report_folder(root / "runs" / "lvs", "LVS", ("match", "clean", "success", "netlists match")))
198+
checks.append(self._check_report_folder(root / "runs" / "antenna", "Antena", ("pass", "clean", "success", "violations: 0")))
199+
return checks
200+
201+
def _check_report_folder(self, folder: Path, title: str, pass_tokens: tuple[str, ...]) -> ReadinessCheck:
202+
reports = []
203+
if folder.is_dir():
204+
for pattern in ("*.log", "*.rpt", "*.report", "*.txt"):
205+
reports.extend(folder.rglob(pattern))
206+
if not reports:
207+
return ReadinessCheck(title, "warning", f"No hay reportes en {folder}", folder)
208+
latest = max(reports, key=lambda path: path.stat().st_mtime)
209+
try:
210+
text = latest.read_text(errors="ignore").lower()
211+
except OSError:
212+
text = ""
213+
if any(token in text for token in pass_tokens) and "error" not in text and "fail" not in text:
214+
return ReadinessCheck(title, "ok", f"Reporte parece OK: {latest.name}", latest)
215+
return ReadinessCheck(title, "warning", f"Revisar reporte manualmente: {latest.name}", latest)
216+
217+
def _check_git_hygiene(self, root: Path) -> list[ReadinessCheck]:
218+
checks: list[ReadinessCheck] = []
219+
gitignore = root / ".gitignore"
220+
if gitignore.is_file():
221+
text = gitignore.read_text(errors="ignore")
222+
if "runs/" in text:
223+
checks.append(ReadinessCheck(".gitignore", "ok", "runs/ esta ignorado", gitignore))
224+
else:
225+
checks.append(ReadinessCheck(".gitignore", "warning", "Conviene ignorar runs/ antes de hacer push", gitignore))
226+
else:
227+
checks.append(ReadinessCheck(".gitignore", "warning", "Falta .gitignore", gitignore))
228+
229+
risky = self._find_risky_paths(root)
230+
if risky:
231+
checks.append(ReadinessCheck("Rutas locales", "warning", f"Posibles rutas locales en {len(risky)} archivo(s)", risky[0]))
232+
else:
233+
checks.append(ReadinessCheck("Rutas locales", "ok", "No se detectaron rutas absolutas obvias", root))
234+
235+
if (root / ".git").exists():
236+
checks.append(ReadinessCheck("Git", "ok", "Repositorio git inicializado", root / ".git"))
237+
else:
238+
checks.append(ReadinessCheck("Git", "warning", "Repositorio git no inicializado todavia", root))
239+
return checks
240+
241+
def _find_risky_paths(self, root: Path) -> list[Path]:
242+
risky: list[Path] = []
243+
skip_dirs = {".git", "runs", "__pycache__"}
244+
pattern = re.compile(r"(/home/|/tmp/|/Users/|C:\\\\)")
245+
for path in root.rglob("*"):
246+
if any(part in skip_dirs for part in path.parts):
247+
continue
248+
if not path.is_file() or path.stat().st_size > 1_000_000:
249+
continue
250+
try:
251+
text = path.read_text(errors="ignore")
252+
except OSError:
253+
continue
254+
if pattern.search(text):
255+
risky.append(path)
256+
return risky

app/core/spice_tools.py

Lines changed: 67 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,10 @@ def _is_valid_node_name(token: str) -> bool:
405405

406406

407407
def _auto_wrapper_lines(source_text: str, preferred_subckt: str, wrapper_options: dict[str, object]) -> list[str]:
408+
wrapper_mode = str(wrapper_options.get("wrapper_mode", "auto")).strip().lower()
409+
if wrapper_mode in {"none", "off", "disabled"}:
410+
return []
411+
408412
if _has_top_level_instances(source_text):
409413
return []
410414

@@ -416,7 +420,7 @@ def _auto_wrapper_lines(source_text: str, preferred_subckt: str, wrapper_options
416420
if not selected_name or not selected_pins:
417421
return []
418422

419-
if _looks_like_tiny_tapeout_wrapper(selected_name, selected_pins):
423+
if wrapper_mode in {"auto", "tiny_tapeout"} and _looks_like_tiny_tapeout_wrapper(selected_name, selected_pins):
420424
return _tiny_tapeout_wrapper_lines(selected_name, selected_pins, wrapper_options)
421425

422426
lines = [
@@ -537,6 +541,12 @@ def _tiny_tapeout_wrapper_lines(subckt_name: str, pins: list[str], wrapper_optio
537541
load_mode = str(wrapper_options.get("tiny_tapeout_load_mode", "cap")).strip().lower()
538542
load_cap_value = str(wrapper_options.get("tiny_tapeout_load_cap_value", "10f")).strip() or "10f"
539543
load_res_value = str(wrapper_options.get("tiny_tapeout_load_res_value", "1k")).strip() or "1k"
544+
pin_config = wrapper_options.get("tiny_tapeout_pin_config", {})
545+
if not isinstance(pin_config, dict):
546+
pin_config = {}
547+
clock_config = wrapper_options.get("tiny_tapeout_clock", {})
548+
if not isinstance(clock_config, dict):
549+
clock_config = {}
540550
lines = [
541551
"",
542552
f"* Auto-generated Tiny Tapeout post-layout wrapper for {subckt_name}",
@@ -548,14 +558,14 @@ def _tiny_tapeout_wrapper_lines(subckt_name: str, pins: list[str], wrapper_optio
548558

549559
added_sources: set[str] = set()
550560
for pin in pins:
551-
source_line = _tiny_tapeout_pin_source(pin)
561+
source_line = _tiny_tapeout_pin_source(pin, pin_config, clock_config)
552562
if source_line and source_line not in added_sources:
553563
added_sources.add(source_line)
554564
lines.append(source_line)
555565

556566
if load_mode not in {"none", "off", "disabled"}:
557567
for pin in pins:
558-
for load_line in _tiny_tapeout_load(pin, load_mode, load_cap_value, load_res_value):
568+
for load_line in _tiny_tapeout_load(pin, load_mode, load_cap_value, load_res_value, pin_config):
559569
lines.append(load_line)
560570

561571
instance_nodes = " ".join(pins)
@@ -569,15 +579,44 @@ def _tiny_tapeout_wrapper_lines(subckt_name: str, pins: list[str], wrapper_optio
569579
return lines
570580

571581

572-
def _tiny_tapeout_pin_source(pin: str) -> str:
582+
def _tiny_tapeout_pin_source(pin: str, pin_config: dict[str, object], clock_config: dict[str, object]) -> str:
573583
normalized = pin.strip().lower()
574584
source_name = re.sub(r"[^a-zA-Z0-9_]+", "_", pin).strip("_") or "pin"
585+
config = _pin_config(pin_config, normalized)
586+
role = str(config.get("role", "")).strip().lower()
587+
588+
if role in {"hiz", "hi_z", "output", "observe", "measurement"}:
589+
return ""
590+
if role in {"ground", "low", "zero"}:
591+
return f"V{source_name} {pin} 0 0"
592+
if role in {"high", "one"}:
593+
return f"V{source_name} {pin} 0 1.8"
594+
if role in {"dc", "input_dc", "bias"}:
595+
value = str(config.get("value", "0")).strip() or "0"
596+
return f"V{source_name} {pin} 0 {value}"
597+
if role in {"sine", "sin", "input_sine"}:
598+
offset = str(config.get("offset", config.get("value", "0"))).strip() or "0"
599+
amplitude = str(config.get("amplitude", "100m")).strip() or "100m"
600+
frequency = str(config.get("frequency", "1Meg")).strip() or "1Meg"
601+
return f"V{source_name} {pin} 0 SIN({offset} {amplitude} {frequency})"
575602

576603
if normalized == "vdpwr":
577604
return f"V{source_name} {pin} 0 1.8"
578605
if normalized in {"vgnd", "vnb", "vpb", "vsub", "vsubs"}:
579606
return f"V{source_name} {pin} 0 0"
580607
if normalized == "clk":
608+
mode = str(clock_config.get("mode", "low")).strip().lower()
609+
if mode in {"pulse", "clock"}:
610+
high = str(clock_config.get("high_time", "5n")).strip() or "5n"
611+
period = str(clock_config.get("period", "10n")).strip() or "10n"
612+
delay = str(clock_config.get("delay", "0")).strip() or "0"
613+
rise = str(clock_config.get("rise", "100p")).strip() or "100p"
614+
fall = str(clock_config.get("fall", "100p")).strip() or "100p"
615+
vlow = str(clock_config.get("vlow", "0")).strip() or "0"
616+
vhigh = str(clock_config.get("vhigh", "1.8")).strip() or "1.8"
617+
return f"V{source_name} {pin} 0 PULSE({vlow} {vhigh} {delay} {rise} {fall} {high} {period})"
618+
if mode in {"high", "one"}:
619+
return f"V{source_name} {pin} 0 1.8"
581620
return f"V{source_name} {pin} 0 0"
582621
if normalized in {"ena", "en", "enable", "rst_n", "reset_n"}:
583622
return f"V{source_name} {pin} 0 1.8"
@@ -591,13 +630,30 @@ def _tiny_tapeout_pin_source(pin: str) -> str:
591630
return ""
592631

593632

594-
def _tiny_tapeout_load(pin: str, load_mode: str, cap_value: str, res_value: str) -> list[str]:
633+
def _tiny_tapeout_load(
634+
pin: str,
635+
load_mode: str,
636+
cap_value: str,
637+
res_value: str,
638+
pin_config: dict[str, object],
639+
) -> list[str]:
595640
normalized = pin.strip().lower()
596641
base_name = re.sub(r"[^a-zA-Z0-9_]+", "_", pin).strip("_") or "node"
597-
eligible = normalized.startswith("uo_out[") or normalized.startswith("uio_out[") or normalized.startswith("osc_")
642+
config = _pin_config(pin_config, normalized)
643+
role = str(config.get("role", "")).strip().lower()
644+
eligible = (
645+
normalized.startswith("uo_out[")
646+
or normalized.startswith("uio_out[")
647+
or normalized.startswith("osc_")
648+
or (normalized.startswith("ua[") and role in {"output", "observe", "measurement"})
649+
)
598650
if not eligible:
599651
return []
600652

653+
load_mode = str(config.get("load_mode", load_mode)).strip().lower() or load_mode
654+
cap_value = str(config.get("load_cap", cap_value)).strip() or cap_value
655+
res_value = str(config.get("load_res", res_value)).strip() or res_value
656+
601657
if load_mode in {"cap", "c"}:
602658
return [f"CLOAD_{base_name} {pin} 0 {cap_value}"]
603659

@@ -611,6 +667,11 @@ def _tiny_tapeout_load(pin: str, load_mode: str, cap_value: str, res_value: str)
611667
return []
612668

613669

670+
def _pin_config(pin_config: dict[str, object], normalized_pin: str) -> dict[str, object]:
671+
config = pin_config.get(normalized_pin) or pin_config.get(normalized_pin.upper()) or {}
672+
return config if isinstance(config, dict) else {}
673+
674+
614675
def _tiny_tapeout_initial_conditions(pins: list[str]) -> str:
615676
normalized_pins = {pin.strip().lower() for pin in pins}
616677
required = [f"uo_out[{index}]" for index in range(4)]

0 commit comments

Comments
 (0)