#!/usr/bin/env python3 """review_prep.py - mechanical groundwork for a code review (stdlib only). python review_prep.py changed files (git) or all source files python review_prep.py src/a.py b.js only these files python review_prep.py --base main git diff against a branch/commit Prints, per file: language, size, the changed line ranges (git), an outline of functions/classes WITH line ranges (so you can read_file exactly one function), compiler/linter results, and a few high-signal hints. Hints and linter output are LEADS to check, not findings. """ import ast, os, re, shutil, subprocess, sys, tempfile from pathlib import Path SRC = {".py": "python", ".js": "js", ".mjs": "js", ".cjs": "js", ".ts": "ts", ".html": "html", ".htm": "html", ".c": "c", ".h": "c", ".cpp": "cpp", ".cc": "cpp", ".hpp": "cpp", ".java": "java", ".php": "php", ".sh": "sh", ".go": "go", ".rs": "rust"} SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv", "dist", "build", "shots", ".idea"} MAX_LINES = 40 # per section, keeps the report small def run(cmd, **kw): try: p = subprocess.run(cmd, capture_output=True, text=True, timeout=60, **kw) return p.returncode, (p.stdout + p.stderr).strip() except (OSError, subprocess.TimeoutExpired) as e: return -1, str(e) def cap(lines, n=MAX_LINES): lines = [l for l in lines if l.strip()] return lines[:n] + ([f"... {len(lines) - n} more"] if len(lines) > n else []) # ------------------------------------------------------------------ scope def git_root(): rc, out = run(["git", "rev-parse", "--show-toplevel"]) return Path(out) if rc == 0 else None def changed_files(base): rc, out = run(["git", "diff", "--name-only", base] if base else ["git", "diff", "--name-only", "HEAD"]) files = out.splitlines() if rc == 0 else [] rc, out = run(["git", "ls-files", "--others", "--exclude-standard"]) files += out.splitlines() if rc == 0 else [] root = git_root() return [Path(os.path.relpath(root / f)) for f in dict.fromkeys(files) if (root / f).is_file() and (root / f).suffix in SRC] def changed_ranges(path, base): """Line ranges of added/modified lines in the NEW file, from the unified diff.""" rc, out = run(["git", "diff", "-U0", base or "HEAD", "--", str(path)]) if rc != 0 or not out: return None # untracked or no git: whole file is "new" rngs = [] for m in re.finditer(r"^@@ -\S+ \+(\d+)(?:,(\d+))? @@", out, re.M): start, n = int(m.group(1)), int(m.group(2) or 1) if n: rngs.append(f"{start}-{start + n - 1}" if n > 1 else str(start)) return rngs def all_files(): out = [] for dp, dns, fns in os.walk("."): dns[:] = [d for d in dns if d not in SKIP_DIRS and not d.startswith(".")] out += [Path(dp) / f for f in fns if Path(f).suffix in SRC] return sorted(out) # ---------------------------------------------------------------- outline def outline_python(text): try: tree = ast.parse(text) except SyntaxError as e: return [f"SyntaxError line {e.lineno}: {e.msg}"], [] rows, hints = [], [] for node in ast.walk(tree): if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): kind = "class" if isinstance(node, ast.ClassDef) else "def" rows.append((node.lineno, f"{node.lineno}-{node.end_lineno} {kind} {node.name}")) if kind == "def": for d in node.args.defaults + node.args.kw_defaults: if isinstance(d, (ast.List, ast.Dict, ast.Set)): hints.append(f"{d.lineno}: mutable default argument in {node.name}()") if isinstance(node, ast.ExceptHandler): if node.type is None: hints.append(f"{node.lineno}: bare 'except:' (also catches KeyboardInterrupt/SystemExit)") if len(node.body) == 1 and isinstance(node.body[0], ast.Pass): hints.append(f"{node.lineno}: exception silently swallowed (except ...: pass)") if isinstance(node, ast.Call): f = node.func name = f.id if isinstance(f, ast.Name) else f.attr if isinstance(f, ast.Attribute) else "" if name in ("eval", "exec"): hints.append(f"{node.lineno}: {name}() - check where the input comes from") if any(k.arg == "shell" and isinstance(k.value, ast.Constant) and k.value.value for k in node.keywords): hints.append(f"{node.lineno}: subprocess with shell=True - check quoting of interpolated values") if isinstance(f, ast.Name) and f.id == "open" and not any(k.arg == "encoding" for k in node.keywords) and \ not (len(node.args) > 1 and isinstance(node.args[1], ast.Constant) and "b" in str(node.args[1].value)): hints.append(f"{node.lineno}: open() without encoding= (platform-dependent on Windows)") if isinstance(node, ast.Compare) and any(isinstance(o, (ast.Eq, ast.NotEq)) for o in node.ops) and \ any(isinstance(c, ast.Constant) and c.value is None for c in node.comparators): hints.append(f"{node.lineno}: '== None' - use 'is None'") return [r for _, r in sorted(rows)], sorted(set(hints), key=lambda h: int(h.split(":")[0])) JS_DEF = re.compile(r"^\s*(?:export\s+)?(?:async\s+)?(?:function\s*\*?\s*(\w+)|class\s+(\w+)|" r"(?:const|let|var)\s+(\w+)\s*=\s*(?:async\s*)?(?:function|\([^)]*\)\s*=>|\w+\s*=>))") C_DEF = re.compile(r"^(?!\s*(?:if|for|while|switch|return|else)\b)[\w:<>,\*\s&]+?\b(\w+)\s*\([^;]*\)\s*(?:const)?\s*\{?\s*$") def outline_regex(text, lang): rows, hints = [], [] lines = text.splitlines() for i, line in enumerate(lines, 1): if lang in ("js", "ts", "html"): m = JS_DEF.match(line) if m: rows.append(f"{i} {next(g for g in m.groups() if g)}") if re.search(r"\son\w+\s*=\s*[\"']", line) and lang == "html": hints.append(f"{i}: inline event handler - with ", text, re.S | re.I), 1): if "src=" in attrs or "importmap" in attrs or not body.strip(): continue line0 = text[:text.find(body)].count("\n") with tempfile.NamedTemporaryFile("w", suffix=".mjs" if "module" in attrs else ".js", delete=False, encoding="utf-8") as f: f.write(body) rc, msg = run(["node", "--check", f.name]) os.unlink(f.name) if rc: m = re.search(r":(\d+)\n", msg) where = f"line ~{line0 + int(m.group(1))}" if m else f"script {i}" out.append(f"node --check {where}: " + " | ".join(node_msg(msg.replace(f.name, "