#!/usr/bin/env python3 # SPDX-FileCopyrightText: 2026 Shamiur Rashid Sunny # SPDX-License-Identifier: AGPL-3.0-only """Master pre-push check for Sunnyware (DevSecOps gate). Runs BEFORE every commit + push: 1. Python syntax check on every src/sunnyware/**/*.py 2. Served HTML sanity (catches the webui.py escape-bug pattern) 3. Latest smoke test (scripts/smoke_test_part*.py by highest part number) 4. Git working-tree check (no unexpected junk / secrets) 5. Live HF Space health check (soft warning only) Exit code 0 = safe to push. Any failure = stop. """ import ast import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent SRC = ROOT / "src" / "sunnyware" SCRIPTS = ROOT / "scripts" PASS = 0 FAIL = 0 WARN = 0 def header(title): print() print("=" * 70) print(" " + title) print("=" * 70) def ok(msg): global PASS PASS += 1 print(" [PASS] " + msg) def bad(msg): global FAIL FAIL += 1 print(" [FAIL] " + msg) def warn(msg): global WARN WARN += 1 print(" [WARN] " + msg) # ── Stage 1: Python syntax ── def stage_syntax(): header("1. Python syntax check") files = sorted(SRC.rglob("*.py")) print(" scanning " + str(len(files)) + " files...") errors = 0 for f in files: try: ast.parse(f.read_text(encoding="utf-8")) except SyntaxError as e: bad(f"{f.relative_to(ROOT)}: line {e.lineno}: {e.msg}") errors += 1 if errors == 0: ok("all " + str(len(files)) + " Python files parse cleanly") # ── Stage 2: Served HTML sanity (escape-bug detector) ── def stage_webui_html(): header("2. Served HTML / JS sanity check") sys.path.insert(0, str(ROOT / "src")) try: from sunnyware import webui except Exception as e: bad("cannot import webui: " + type(e).__name__ + ": " + str(e)) return html = webui.HTML print(" runtime HTML: " + str(len(html)) + " chars") # Detect unescaped newlines inside single-quoted JS strings # Heuristic: a single quote, then newline, then spaces, then more content on next line lines = html.splitlines() bugs = [] for i, ln in enumerate(lines): stripped = ln.rstrip() # line ends with an open single-quote (odd count) single_quotes = stripped.count("'") double_quotes = stripped.count('"') if single_quotes % 2 == 1 and not stripped.endswith("\\'"): # odd number of single-quotes at line end -> probably opens a string # that never closes on this line (very likely the escape bug) if "//" not in stripped[-30:]: bugs.append((i + 1, stripped[-60:])) if bugs: for lineno, snippet in bugs[:5]: bad(f"line {lineno} ends with unclosed quote: ...{snippet!r}") warn(f"{len(bugs)} potential broken-string lines (manual review if listed)") else: ok("no obvious broken JS string literals detected") # Basic sanity — key markers present markers = ['id="chat"', 'id="sidebar"', 'id="newChatBtn"', 'sendMessage'] missing = [m for m in markers if m not in html] if missing: warn("missing markers: " + ", ".join(missing)) else: ok("all key HTML markers present") # ── Stage 3: Latest part-specific smoke test ── def stage_smoke_test(): header("3. Part-specific smoke test") tests = sorted(SCRIPTS.glob("smoke_test_part*.py")) if not tests: warn("no smoke_test_partNN.py found — skipping (recommend adding one)") return # Highest part number def part_num(p): name = p.stem # smoke_test_part39 try: return int(name.rsplit("part", 1)[1]) except Exception: return 0 tests.sort(key=part_num) latest = tests[-1] print(" running: " + latest.name) r = subprocess.run( [sys.executable, str(latest)], cwd=str(ROOT), capture_output=True, text=True, timeout=120, ) for ln in (r.stdout or "").splitlines(): print(" " + ln) if r.returncode == 0: ok(latest.name + " passed") else: if r.stderr: print(" stderr:") for ln in r.stderr.splitlines()[:10]: print(" " + ln) bad(latest.name + " failed (exit " + str(r.returncode) + ")") # ── Stage 4: Git working-tree check ── def stage_git_status(): header("4. Git working-tree check") try: r = subprocess.run( ["git", "status", "--porcelain"], cwd=str(ROOT), capture_output=True, text=True, timeout=10, ) except Exception as e: bad("git status failed: " + str(e)) return lines = [ln for ln in (r.stdout or "").splitlines() if ln.strip()] if not lines: ok("working tree clean — nothing to commit") return print(" changes:") suspicious = [] secret_patterns = (".env", "id_rsa", ".pem", ".key", "credentials") # Part 107: allow example/template files (they have empty values # and are meant to be committed) secret_whitelist = (".env.example", ".env.sample", ".env.template", ".env.dist") junk_patterns = (".pyc", "__pycache__", ".bak", ".tmp", ".log", ".DS_Store") for ln in lines: print(" " + ln) low = ln.lower() if any(s in low for s in secret_patterns) and not any( w in low for w in secret_whitelist ): suspicious.append(("secret", ln)) if any(j in low for j in junk_patterns): suspicious.append(("junk", ln)) if suspicious: for kind, ln in suspicious: bad(f"{kind} file in changeset: {ln}") else: ok(str(len(lines)) + " change(s), no secrets or junk detected") # ── Stage 5: Live HF health (soft) ── def stage_hf_health(): header("5. Live HF Space health (soft check)") try: import httpx except ImportError: warn("httpx not installed — skipping live check") return try: r = httpx.get( "https://shamiur-sunnyware.hf.space/health/ready", timeout=10.0, ) if r.status_code == 200: d = r.json() st = d.get("status") tools = (d.get("checks") or {}).get("tools") if st == "ok": ok("HF Space healthy · tools=" + str(tools)) else: warn("HF Space returned status=" + str(st)) else: warn("HF Space HTTP " + str(r.status_code) + " — may be mid-rebuild") except Exception as e: warn("HF Space unreachable: " + type(e).__name__ + " — push anyway") # ── Run all stages ── def main(): print() print("#" * 70) print("# Sunnyware Pre-Push Check") print("#" * 70) stage_syntax() stage_webui_html() stage_smoke_test() stage_git_status() stage_hf_health() print() print("=" * 70) print(" SUMMARY: " + str(PASS) + " passed · " + str(FAIL) + " failed · " + str(WARN) + " warnings") print("=" * 70) if FAIL > 0: print() print(" RESULT: BLOCKED — fix failures before pushing.") print() return 1 print() print(" RESULT: READY TO PUSH.") print() return 0 if __name__ == "__main__": sys.exit(main())