244 lines
12 KiB
Python
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:])
|