diff --git a/ghost-ai-scanner/src/chat/engine.py b/ghost-ai-scanner/src/chat/engine.py index ff66f02..ed14cdc 100644 --- a/ghost-ai-scanner/src/chat/engine.py +++ b/ghost-ai-scanner/src/chat/engine.py @@ -50,6 +50,7 @@ "get_shadow_ai_census": get_shadow_ai_census, "get_recent_activity": get_recent_activity, "compare_periods": compare_periods, + } # Tools that take **kwargs only (no scope). diff --git a/scripts/check_code_model.py b/scripts/check_code_model.py new file mode 100644 index 0000000..ca40785 --- /dev/null +++ b/scripts/check_code_model.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +# ============================================================================= +# scripts/check_code_model.py — Structural model compliance checker +# Author: Giggso Inc / Ravi Venugopal +# Purpose: Verifies code follows PatronAI/Marauder Scan build patterns +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +import ast +import re +import sys +import subprocess +from pathlib import Path + +CX_ORACLE_RE = re.compile(r"\bimport cx_Oracle\b|from cx_Oracle\b") +RAW_FITZ_RE = re.compile(r"^import fitz\b", re.MULTILINE) +DOTENV_IN_FN_RE = re.compile(r"def\s+\w+[^:]*:\s*\n(?:[^\n]*\n)*?[^\n]*load_dotenv\(\)") +CAMEL_VAR_RE = re.compile(r"^\s{4,}[a-z]+[A-Z]\w*\s*=", re.MULTILINE) +LLM_CALL_RE = re.compile(r"\.chat\.completions\.create|openai\.ChatCompletion") +PREPROCESS_RE = re.compile(r"preprocess_text\s*\(") +TEMP_ZERO_RE = re.compile(r"temperature\s*=\s*0\b") +COUNT_TOKENS_RE = re.compile(r"count_tokens\s*\(") + + +def get_staged_files() -> list[str]: + """Return staged Python files via git diff.""" + try: + r = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, text=True, check=True, + ) + return [f for f in r.stdout.strip().splitlines() if f.endswith(".py")] + except subprocess.CalledProcessError as e: + sys.stderr.write(f"[model] git diff failed: {e}\n") + return [] + + +def check_import_order(tree: ast.Module, path: str) -> list[str]: + """Flag imports that appear after function/class definitions.""" + v: list[str] = [] + saw_def = False + for node in ast.iter_child_nodes(tree): + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + saw_def = True + elif isinstance(node, (ast.Import, ast.ImportFrom)) and saw_def: + v.append( + f"[MODEL FAIL] {path}:{node.lineno} — import after definition — " + "move all imports to the top of the file" + ) + return v + + +def check_llm_conventions(content: str, path: str) -> list[str]: + """Verify LLM calls follow preprocess → count_tokens → temperature=0 pattern.""" + v: list[str] = [] + if not LLM_CALL_RE.search(content): + return v + if not PREPROCESS_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — LLM call without preprocess_text() — " + "strip noise before sending to API" + ) + if not COUNT_TOKENS_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — LLM call without count_tokens() — " + "log token count to stderr before every API call" + ) + if not TEMP_ZERO_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — LLM call missing temperature=0 — " + "enforce deterministic output on all API calls" + ) + return v + + +def check_file(path: str) -> list[str]: + """Run all model compliance rules on one file.""" + v: list[str] = [] + try: + content = Path(path).read_text(encoding="utf-8", errors="ignore") + except OSError as e: + return [f"[MODEL FAIL] {path} — cannot read: {e}"] + + if CX_ORACLE_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — cx_Oracle import — " + "replace with oracledb (CLAUDE.md stack rule)" + ) + if RAW_FITZ_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — bare 'import fitz' — " + "use 'import pymupdf as fitz' instead" + ) + if CAMEL_VAR_RE.search(content): + v.append( + f"[MODEL FAIL] {path} — camelCase variable detected — " + "use snake_case for all variables and functions" + ) + + v.extend(check_llm_conventions(content, path)) + + try: + tree = ast.parse(content) + v.extend(check_import_order(tree, path)) + except SyntaxError: + pass # syntax errors are reported by check_code_quality.py + + return v + + +def main() -> int: + """Entry point. Exit 0 = pass, 1 = violations found.""" + files = sys.argv[1:] or get_staged_files() + if not files: + print("[MODEL PASS] No Python files to check") + return 0 + + violations: list[str] = [] + for path in files: + violations.extend(check_file(path)) + + for msg in violations: + print(msg) + + if not violations: + print("[MODEL PASS] All structural model checks passed") + return 0 + + print(f"\n[MODEL BLOCK] {len(violations)} violation(s) — fix before push") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_code_quality.py b/scripts/check_code_quality.py new file mode 100644 index 0000000..6190cfe --- /dev/null +++ b/scripts/check_code_quality.py @@ -0,0 +1,135 @@ +#!/usr/bin/env python3 +# ============================================================================= +# scripts/check_code_quality.py — CLAUDE.md code quality gate +# Author: Giggso Inc / Ravi Venugopal +# Purpose: AST-based checker for pre-push quality enforcement +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +import ast +import re +import sys +import subprocess +from pathlib import Path + +MAX_LINES = 150 +HEADER_RE = re.compile(r"#.*?(file:|purpose:|author:|={10,})", re.IGNORECASE) +AUDIT_RE = re.compile(r"#.*?audit\s*log", re.IGNORECASE) +CRED_RE = re.compile( + r'(api_key|password|secret_key|token|passwd)\s*=\s*["\'][^"\']{4,}', + re.IGNORECASE, +) +PRINT_RE = re.compile(r"^\s*print\s*\(", re.MULTILINE) +MCP_PATH_RE = re.compile(r"mcp", re.IGNORECASE) +EXTERNAL_CALLS = re.compile( + r"\b(requests\.|boto3\.|openai\.|subprocess\.|open\(|oracledb\.)", re.MULTILINE +) + + +def get_staged_files() -> list[str]: + """Return staged Python files via git diff.""" + try: + r = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, text=True, check=True, + ) + return [f for f in r.stdout.strip().splitlines() if f.endswith(".py")] + except subprocess.CalledProcessError as e: + sys.stderr.write(f"[quality] git diff failed: {e}\n") + return [] + + +def _line_no(content: str, offset: int) -> int: + """Convert string offset to 1-based line number.""" + return content[:offset].count("\n") + 1 + + +def check_file(path: str) -> list[str]: + """Run all quality rules on one file. Returns list of violation strings.""" + v: list[str] = [] + try: + content = Path(path).read_text(encoding="utf-8", errors="ignore") + except OSError as e: + return [f"[QUALITY FAIL] {path} — cannot read: {e} — check file permissions"] + + lines = content.splitlines() + + if len(lines) > MAX_LINES: + v.append( + f"[QUALITY FAIL] {path}:{len(lines)} — exceeds 150-line limit " + f"({len(lines)} lines) — split into smaller modules" + ) + if not HEADER_RE.search(content[:600]): + v.append( + f"[QUALITY FAIL] {path}:1 — missing file header — " + "add filename/purpose/author block at top" + ) + if not AUDIT_RE.search(content): + v.append( + f"[QUALITY FAIL] {path}:1 — missing audit log table — " + "add '# | Date | Author | Change |' table" + ) + for m in CRED_RE.finditer(content): + v.append( + f"[QUALITY FAIL] {path}:{_line_no(content, m.start())} — " + f"hardcoded credential '{m.group(1)}' — use os.getenv() + .env" + ) + if MCP_PATH_RE.search(path): + for m in PRINT_RE.finditer(content): + v.append( + f"[QUALITY FAIL] {path}:{_line_no(content, m.start())} — " + "print() in MCP server — use sys.stderr.write() instead" + ) + + try: + tree = ast.parse(content) + except SyntaxError as e: + v.append(f"[QUALITY FAIL] {path}:{e.lineno} — syntax error: {e.msg}") + return v + + for node in ast.walk(tree): + if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + args = node.args.args + node.args.posonlyargs + node.args.kwonlyargs + missing = [a.arg for a in args if a.annotation is None and a.arg != "self"] + if missing or node.returns is None: + v.append( + f"[QUALITY FAIL] {path}:{node.lineno} — {node.name}() missing " + f"type hints ({', '.join(missing) or 'return'}) — annotate all params + return" + ) + if not ast.get_docstring(node): + v.append( + f"[QUALITY FAIL] {path}:{node.lineno} — {node.name}() " + "missing docstring — add purpose comment" + ) + + return v + + +def main() -> int: + """Entry point. Exit 0 = pass, 1 = violations found.""" + files = sys.argv[1:] or get_staged_files() + if not files: + print("[QUALITY PASS] No Python files to check") + return 0 + + violations: list[str] = [] + for path in files: + violations.extend(check_file(path)) + + for msg in violations: + print(msg) + + if not violations: + print("[QUALITY PASS] All quality checks passed") + return 0 + + print(f"\n[QUALITY BLOCK] {len(violations)} violation(s) — fix before push") + return 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/hooks/pre-push b/scripts/hooks/pre-push new file mode 100755 index 0000000..524bf69 --- /dev/null +++ b/scripts/hooks/pre-push @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/hooks/pre-push — PatronAI pre-push quality gate +# Author: Giggso Inc / Ravi Venugopal +# Purpose: Blocks push on quality/model/vuln failures; advisory PR review async +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +PYTHON="${PYTHON:-python3}" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +echo "" +echo -e "${CYAN}═══════════════════════════════════════════════════${NC}" +echo -e "${CYAN} PatronAI Pre-Push Quality Gate${NC}" +echo -e "${CYAN}═══════════════════════════════════════════════════${NC}" + +# ── Step 1: Code Quality ───────────────────────────────────────────────────── +echo "" +echo -e "▶ ${CYAN}[1/3] Code Quality Check...${NC}" +if ! "$PYTHON" "$REPO_ROOT/scripts/check_code_quality.py"; then + echo "" + echo -e "${RED}✗ PUSH BLOCKED — Code quality violations detected above.${NC}" + echo -e "${YELLOW} → Fix all [QUALITY FAIL] items, re-stage, and retry push.${NC}" + exit 1 +fi +echo -e "${GREEN} ✓ Code quality passed${NC}" + +# ── Step 2: Code Model ─────────────────────────────────────────────────────── +echo "" +echo -e "▶ ${CYAN}[2/3] Structural Model Check...${NC}" +if ! "$PYTHON" "$REPO_ROOT/scripts/check_code_model.py"; then + echo "" + echo -e "${RED}✗ PUSH BLOCKED — Structural model violations detected above.${NC}" + echo -e "${YELLOW} → Fix all [MODEL FAIL] items, re-stage, and retry push.${NC}" + exit 1 +fi +echo -e "${GREEN} ✓ Code model compliance passed${NC}" + +# ── Step 3: Vulnerability Scan ─────────────────────────────────────────────── +echo "" +echo -e "▶ ${CYAN}[3/3] GPT-5.5 Vulnerability Scan...${NC}" +if ! "$PYTHON" "$REPO_ROOT/scripts/vuln_scan.py"; then + echo "" + echo -e "${RED}✗ PUSH BLOCKED — CRITICAL/HIGH vulnerabilities found above.${NC}" + echo -e "${YELLOW} → Fix all [VULN CRITICAL] and [VULN HIGH] items, re-stage, and retry push.${NC}" + exit 1 +fi +echo -e "${GREEN} ✓ Vulnerability scan passed${NC}" + +# ── All gates passed ───────────────────────────────────────────────────────── +echo "" +echo -e "${GREEN}═══════════════════════════════════════════════════${NC}" +echo -e "${GREEN} ✓ All gates passed — push proceeding${NC}" +echo -e "${GREEN}═══════════════════════════════════════════════════${NC}" +echo "" + +# ── PR Review (async, advisory — never blocks push) ────────────────────────── +"$PYTHON" "$REPO_ROOT/scripts/pr_review.py" & +disown +echo -e "${CYAN} ↪ PR review running in background — check GitHub for comments${NC}" +echo "" diff --git a/scripts/install-hooks.sh b/scripts/install-hooks.sh new file mode 100755 index 0000000..86d2fcc --- /dev/null +++ b/scripts/install-hooks.sh @@ -0,0 +1,41 @@ +#!/usr/bin/env bash +# ============================================================================= +# scripts/install-hooks.sh — Install PatronAI git hooks from scripts/hooks/ +# Author: Giggso Inc / Ravi Venugopal +# Purpose: One-time setup; symlinks scripts/hooks/* into .git/hooks/ +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +set -euo pipefail + +REPO_ROOT="$(git rev-parse --show-toplevel)" +SRC="$REPO_ROOT/scripts/hooks" +DST="$REPO_ROOT/.git/hooks" + +install_hook() { + local name="$1" + if [ ! -f "$SRC/$name" ]; then + echo " ⚠ $name not found in scripts/hooks/ — skipping" + return + fi + cp "$SRC/$name" "$DST/$name" + chmod +x "$DST/$name" + echo " ✓ Installed $name" +} + +echo "" +echo "Installing PatronAI git hooks..." +echo "" +install_hook "pre-push" +echo "" +echo "Done. Every 'git push' will now run:" +echo " [1/3] Code quality check" +echo " [2/3] Structural model check" +echo " [3/3] GPT-5.5 vulnerability scan" +echo " [async] PR review → GitHub comments" +echo "" +echo "Requires OPENAI_API_KEY in .env and 'gh' CLI authenticated." +echo "" diff --git a/scripts/pr_review.py b/scripts/pr_review.py new file mode 100644 index 0000000..c4edeb8 --- /dev/null +++ b/scripts/pr_review.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +# ============================================================================= +# scripts/pr_review.py — Automated PR review + GitHub comment poster +# Author: Giggso Inc / Ravi Venugopal +# Purpose: GPT-5.5 diff review; conflict detection; posts via gh CLI +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +import json +import os +import sys +import subprocess +from dotenv import load_dotenv +import openai + +load_dotenv() + +MODEL = "gpt-5.5" +MAX_DIFF_CHARS = 15_000 + + +def run_cmd(cmd: list[str]) -> tuple[int, str, str]: + """Run shell command. Returns (returncode, stdout, stderr).""" + try: + r = subprocess.run(cmd, capture_output=True, text=True) + return r.returncode, r.stdout.strip(), r.stderr.strip() + except FileNotFoundError as e: + return 1, "", str(e) + + +def preprocess_text(text: str) -> str: + """Strip blank lines and trailing whitespace before sending to LLM.""" + lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()] + return "\n".join(lines) + + +def count_tokens(text: str) -> int: + """Estimate token count (4 chars ≈ 1 token) and log to stderr.""" + estimate = len(text) // 4 + sys.stderr.write(f"[pr_review] token estimate: ~{estimate}\n") + return estimate + + +def get_pr_info() -> dict | None: + """Fetch open PR metadata for current branch via gh CLI.""" + code, out, err = run_cmd( + ["gh", "pr", "view", "--json", "number,title,mergeable,url,state"] + ) + if code != 0: + sys.stderr.write(f"[pr_review] no open PR: {err}\n") + return None + try: + return json.loads(out) + except json.JSONDecodeError as e: + sys.stderr.write(f"[pr_review] JSON parse error: {e}\n") + return None + + +def get_pr_diff() -> str: + """Fetch PR diff text via gh CLI.""" + code, out, _ = run_cmd(["gh", "pr", "diff"]) + return out if code == 0 else "" + + +def review_with_llm(diff: str, pr_title: str) -> str: + """Send diff to GPT-5.5 and return a markdown review comment.""" + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + return "[PR-REVIEW ERROR] OPENAI_API_KEY not set in .env" + + clean_diff = preprocess_text(diff[:MAX_DIFF_CHARS]) + count_tokens(clean_diff) + + prompt = ( + f'Senior code review for PR: "{pr_title}"\n\n' + f"```diff\n{clean_diff}\n```\n\n" + "Write a concise GitHub PR review in markdown. Include:\n" + "1. Summary of changes (2–3 lines)\n" + "2. Issues: bugs, missing type hints, CLAUDE.md violations, security concerns\n" + "3. Positives if any\n" + "4. Verdict: APPROVE / REQUEST_CHANGES / COMMENT\n" + "Be direct. No filler." + ) + + try: + client = openai.OpenAI(api_key=api_key) + resp = client.chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0, + ) + return resp.choices[0].message.content + except openai.APIError as e: + sys.stderr.write(f"[pr_review] OpenAI error: {e}\n") + return f"[PR-REVIEW ERROR] API call failed: {e}" + + +def post_comment(body: str) -> bool: + """Post a review comment to the open PR via gh CLI.""" + code, _, err = run_cmd(["gh", "pr", "review", "--comment", "-b", body]) + if code != 0: + sys.stderr.write(f"[pr_review] failed to post comment: {err}\n") + return False + return True + + +def main() -> int: + """Entry point. Always exits 0 — PR review is advisory, never blocks.""" + pr = get_pr_info() + if not pr: + print("[PR-REVIEW] No open PR on this branch — skipping") + return 0 + + pr_num = pr.get("number") + pr_title = pr.get("title", "") + mergeable = pr.get("mergeable", "MERGEABLE") + pr_url = pr.get("url", "") + + sys.stderr.write(f"[pr_review] reviewing PR #{pr_num}: {pr_title}\n") + + if mergeable == "CONFLICTING": + conflict_msg = ( + "## ⚠️ CONFLICT DETECTED\n\n" + "**Human review required** — this PR has merge conflicts " + "that must be resolved manually before merge.\n\n" + "_Posted by automated pre-push review gate._" + ) + post_comment(conflict_msg) + print(f"[PR-REVIEW CONFLICT] PR #{pr_num} has conflicts — human review required") + return 0 + + diff = get_pr_diff() + if not diff: + print("[PR-REVIEW] Empty diff — nothing to review") + return 0 + + review_body = review_with_llm(diff, pr_title) + if post_comment(review_body): + print(f"[PR-REVIEW DONE] Review posted on PR #{pr_num} — {pr_url}") + else: + print("[PR-REVIEW WARN] Review generated but failed to post to GitHub") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/vuln_scan.py b/scripts/vuln_scan.py new file mode 100644 index 0000000..12f2119 --- /dev/null +++ b/scripts/vuln_scan.py @@ -0,0 +1,130 @@ +#!/usr/bin/env python3 +# ============================================================================= +# scripts/vuln_scan.py — GPT-5.5 vulnerability scanner (pre-push gate) +# Author: Giggso Inc / Ravi Venugopal +# Purpose: OWASP Top 10 + secrets + AWS IAM scan via OpenAI gpt-5.5 +# ============================================================================= +# | Date | Author | Change | +# |------------|--------|--------------------------------| +# | 2026-05-08 | RV | Initial implementation | +# ============================================================================= + +import json +import os +import sys +import subprocess +from pathlib import Path +from dotenv import load_dotenv +import openai + +load_dotenv() + +MODEL = "gpt-5.5" +MAX_FILE_CHARS = 10_000 # ~2500 tokens per file ceiling + + +def get_staged_files() -> list[str]: + """Return staged Python files via git diff.""" + try: + r = subprocess.run( + ["git", "diff", "--cached", "--name-only", "--diff-filter=ACM"], + capture_output=True, text=True, check=True, + ) + return [f for f in r.stdout.strip().splitlines() if f.endswith(".py")] + except subprocess.CalledProcessError as e: + sys.stderr.write(f"[vuln_scan] git diff failed: {e}\n") + return [] + + +def preprocess_text(text: str) -> str: + """Strip blank lines and trailing whitespace to reduce token noise.""" + lines = [ln.rstrip() for ln in text.splitlines() if ln.strip()] + return "\n".join(lines) + + +def count_tokens(text: str) -> int: + """Estimate token count (4 chars ≈ 1 token). Logs to stderr.""" + estimate = len(text) // 4 + sys.stderr.write(f"[vuln_scan] token estimate: ~{estimate}\n") + return estimate + + +def scan_file(client: openai.OpenAI, path: str) -> list[dict]: + """Send one file to GPT-5.5 and return parsed vulnerability list.""" + try: + raw = Path(path).read_text(encoding="utf-8", errors="ignore")[:MAX_FILE_CHARS] + except OSError as e: + sys.stderr.write(f"[vuln_scan] cannot read {path}: {e}\n") + return [] + + content = preprocess_text(raw) + count_tokens(content) + + prompt = ( + f'Security audit this Python file: "{path}"\n\n' + f"```python\n{content}\n```\n\n" + "Return JSON: {\"vulnerabilities\": [" + "{\"severity\": \"CRITICAL|HIGH|MEDIUM|INFO\", " + "\"line\": , \"description\": \"\", " + "\"fix\": \"\", \"ref\": \"\"}" + "]}. Empty array if clean. JSON only." + ) + + sys.stderr.write(f"[vuln_scan] scanning {path} with {MODEL}...\n") + try: + resp = client.chat.completions.create( + model=MODEL, + messages=[{"role": "user", "content": prompt}], + temperature=0, + response_format={"type": "json_object"}, + ) + parsed = json.loads(resp.choices[0].message.content) + return parsed.get("vulnerabilities", []) + except (openai.APIError, json.JSONDecodeError, KeyError) as e: + sys.stderr.write(f"[vuln_scan] API/parse error for {path}: {e}\n") + return [] + + +def main() -> int: + """Entry point. Exit 0 = pass/advisory, 1 = CRITICAL/HIGH found.""" + api_key = os.getenv("OPENAI_API_KEY") + if not api_key: + sys.stderr.write("[vuln_scan] ERROR: OPENAI_API_KEY not set in .env\n") + return 1 + + files = sys.argv[1:] or get_staged_files() + if not files: + print("[VULN PASS] No Python files to scan") + return 0 + + client = openai.OpenAI(api_key=api_key) + blocking = False + total = 0 + + for path in files: + issues = scan_file(client, path) + for issue in issues: + sev = issue.get("severity", "INFO") + line = issue.get("line", "?") + desc = issue.get("description", "") + fix = issue.get("fix", "") + ref = issue.get("ref", "") + print(f"[VULN {sev}] {path}:{line} — {desc} — {ref} — Fix: {fix}") + total += 1 + if sev in ("CRITICAL", "HIGH"): + blocking = True + + if total == 0: + print("[VULN PASS] No vulnerabilities found") + return 0 + + if blocking: + print(f"\n[VULN BLOCK] {total} issue(s) — CRITICAL/HIGH must be fixed before push") + return 1 + + print(f"\n[VULN ADVISORY] {total} MEDIUM/INFO issue(s) — push allowed, review recommended") + return 0 + + +if __name__ == "__main__": + sys.exit(main())