jcode/skills/code-review/review_prep.py

244 lines
12 KiB
Python

#!/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 <script type=module> it cannot reach module scope")
if re.search(r"\binnerHTML\s*=(?!=)", line):
hints.append(f"{i}: innerHTML assignment - XSS if the value contains user input")
if re.search(r"[^=!]==[^=]", line) and not re.search(r"['\"].*==.*['\"]", line):
hints.append(f"{i}: '==' (loose equality)")
elif lang in ("c", "cpp", "java") and C_DEF.match(line) and not line.startswith((" ", "\t")):
rows.append(f"{i} {C_DEF.match(line).group(1)}")
if lang in ("c", "cpp") and re.search(r"\b(gets|strcpy|strcat|sprintf)\s*\(", line):
hints.append(f"{i}: unbounded {re.search(r'(gets|strcpy|strcat|sprintf)', line).group(1)}()")
if re.search(r"\b(TODO|FIXME|XXX|HACK)\b", line):
hints.append(f"{i}: {line.strip()[:90]}")
return rows, hints
# ---------------------------------------------------------- tools/linters
def node_msg(msg):
"""node --check output without stack frames: 'file:line', the source line, the error."""
keep = [l for l in msg.splitlines() if l.strip() and not l.lstrip().startswith("at ")
and not l.startswith("Node.js v")]
return keep[:4]
def html_scripts_check(path, text):
if not shutil.which("node"):
return []
out = []
for i, (attrs, body) in enumerate(re.findall(r"<script\b([^>]*)>(.*?)</script>", 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, "<script>"))))
return out
TOOLS = {"python": [sys.executable], "js": ["node"], "html": ["node"], "c": ["gcc", "clang"],
"cpp": ["g++", "clang++", "clang"], "php": ["php"], "sh": ["bash"]}
def linters(path, lang, text):
"""List of messages, or None if no checker for this language is installed."""
if not any(shutil.which(x) for x in TOOLS.get(lang, [])):
return None
p, out = str(path), []
if lang == "python":
if shutil.which("ruff"):
# --isolated: ignore local style configs; only rules that indicate bugs
out += run(["ruff", "check", "--isolated", "--quiet", "--output-format=concise",
"--select", "E9,F,B,PLE,PLW0120,RUF006", "--ignore", "F541", p])[1].splitlines()
else:
rc, msg = run([sys.executable, "-m", "pyflakes", p])
if "No module named pyflakes" not in msg:
out += msg.splitlines()
rc, msg = run([sys.executable, "-m", "py_compile", p])
out += msg.splitlines() if rc else []
elif lang == "js" and shutil.which("node"):
rc, msg = run(["node", "--check", p]); out += node_msg(msg) if rc else []
elif lang == "html":
out += html_scripts_check(path, text)
elif lang in ("c", "cpp"):
cc = next(filter(None, map(shutil.which, TOOLS[lang])), None)
if cc:
# real compile (not -fsyntax-only): flow warnings like missing return / uninitialized need it
rc, msg = run([cc, "-O1", "-Wall", "-Wextra", "-c", "-o", os.devnull, p]); out += msg.splitlines()
elif lang == "php" and shutil.which("php"):
rc, msg = run(["php", "-l", p]); out += msg.splitlines() if rc else []
elif lang == "sh" and shutil.which("bash"):
rc, msg = run(["bash", "-n", p]); out += msg.splitlines()
if shutil.which("shellcheck"):
out += run(["shellcheck", "-f", "gcc", p])[1].splitlines()
for name in (os.path.abspath(p), p, os.path.relpath(p)): # longest first
out = [l.replace(name + ":", "line ") for l in out]
return list(dict.fromkeys(out))
# ------------------------------------------------------------------- main
def main(argv):
base = None
if "--base" in argv:
i = argv.index("--base"); base = argv[i + 1]; del argv[i:i + 2]
in_git = git_root() is not None
if argv:
files, mode = [Path(a) for a in argv], "given files"
elif in_git and (files := changed_files(base)):
mode = f"changed vs {base or 'HEAD'} (git)"
else:
files, mode = all_files(), "all source files"
print(f"# Review prep - {len(files)} file(s), scope: {mode}\n")
for path in files:
if not path.is_file():
print(f"## {path}\n(not found)\n"); continue
text = path.read_text(encoding="utf-8", errors="replace")
lang = SRC.get(path.suffix, "?")
rows, hints = outline_python(text) if lang == "python" else outline_regex(text, lang)
print(f"## {path} ({lang}, {text.count(chr(10)) + 1} lines)")
if in_git and not argv or base:
rngs = changed_ranges(path, base)
print("changed lines: " + (", ".join(rngs) if rngs else "whole file (new/untracked)"))
if rows:
print("outline (line range / start line):"); print("\n".join(" " + r for r in cap(rows, 60)))
lint = linters(path, lang, text)
if lint is None:
print(f"compiler/linter: NOT CHECKED - no {'/'.join(TOOLS.get(lang, ['checker']))} installed for {lang}")
else:
print("compiler/linter: " + ("clean" if not lint else ""))
if lint:
print("\n".join(" " + l for l in cap(lint)))
if hints:
print("hints (verify before reporting):"); print("\n".join(" " + h for h in cap(hints)))
print()
if __name__ == "__main__":
main(sys.argv[1:])