|
| 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 |
0 commit comments