Same rule as 6e294f543d for the reads that arrived with the origin/main merge;
the Windows footguns lane is blocking.
162 lines
6.8 KiB
Python
162 lines
6.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Advisory lint: profile-scope hazard patterns on the lines a change adds.
|
|
|
|
One Hermes process may serve many profiles (multiplex gateway, Desktop/dashboard ``serve``), and
|
|
``os.environ`` / module globals hold only the LAUNCH profile's values. Every pattern in
|
|
``scripts/ci/profile_scope_patterns.json`` is a call-site shape that turned out to be
|
|
profile-sensitive at least once — a child env built from ``os.environ``, a raw ``os.getenv`` of a
|
|
platform credential, an RPC decorator that binds the home but not the secret scope, a bare PID
|
|
liveness check. The invariant itself is in the root ``AGENTS.md`` (§ Code Shape Rules).
|
|
|
|
Advisory by construction: it prints ``file:line <id>/<class> why`` for every hit and ALWAYS
|
|
exits 0, because most patterns have legitimate sites (a standalone ``hermes -p x`` process where
|
|
environ IS the profile). The reviewer reads each finding against its ``scope_hint``.
|
|
|
|
Usage:
|
|
python scripts/check_profile_scope_patterns.py [--base origin/main] [--head HEAD]
|
|
python scripts/check_profile_scope_patterns.py --files tools/bot_relay.py ... # whole files
|
|
python scripts/check_profile_scope_patterns.py --base origin/main --json out.json
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from dataclasses import asdict, dataclass
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
PATTERNS = ROOT / "scripts" / "ci" / "profile_scope_patterns.json"
|
|
SUFFIXES = (".py", ".ts", ".tsx")
|
|
SKIP_PREFIXES = ("tests/", "website/", "skills/", "optional-skills/", "evals/", "scripts/", ".worktrees/")
|
|
_HUNK_RE = re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Finding:
|
|
path: str
|
|
line: int
|
|
pattern_id: str
|
|
pattern_class: str
|
|
why: str
|
|
scope_hint: str
|
|
text: str
|
|
|
|
|
|
def load_patterns(path: Path = PATTERNS) -> list[dict]:
|
|
data = json.loads(path.read_text(encoding="utf-8-sig"))
|
|
out = []
|
|
for p in data["patterns"]:
|
|
# ``path_regex`` (optional) restricts a pattern to files whose repo-relative path matches.
|
|
path_rx = re.compile(p["path_regex"]) if p.get("path_regex") else None
|
|
out.append({**p, "_rx": re.compile(p["pattern_regex"], re.M), "_path_rx": path_rx})
|
|
return out
|
|
|
|
|
|
def _lint_path(rel: str) -> bool:
|
|
return rel.endswith(SUFFIXES) and not rel.startswith(SKIP_PREFIXES) and "/tests/" not in rel and "node_modules" not in rel
|
|
|
|
|
|
def scan_text(rel: str, text: str, patterns: list[dict], lines: set[int] | None = None) -> list[Finding]:
|
|
"""Findings for *text*; ``lines`` restricts to those 1-based line numbers (None = whole file).
|
|
Multi-line patterns are anchored on the line where the match starts."""
|
|
findings: list[Finding] = []
|
|
src_lines = text.split("\n")
|
|
for p in patterns:
|
|
path_rx = p.get("_path_rx")
|
|
if path_rx is not None and not path_rx.search(rel):
|
|
continue
|
|
for m in p["_rx"].finditer(text):
|
|
line_no = text.count("\n", 0, m.start()) + 1
|
|
if lines is not None and line_no not in lines:
|
|
continue
|
|
findings.append(Finding(rel, line_no, p["id"], p["class"], p["why"], p["scope_hint"],
|
|
src_lines[line_no - 1].strip()[:160]))
|
|
return findings
|
|
|
|
|
|
def _git(*args: str) -> str:
|
|
proc = subprocess.run(["git", *args], cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
return proc.stdout
|
|
|
|
|
|
def added_lines_vs_base(base: str, head: str | None) -> dict[str, set[int]]:
|
|
"""``{path: {added line numbers in head}}`` for lint-able files changed between *base* and *head*
|
|
(working tree when *head* is None)."""
|
|
rev = [base, head] if head else [base]
|
|
diff = _git("diff", "--no-color", "-U0", "--diff-filter=AM", *rev, "--")
|
|
out: dict[str, set[int]] = {}
|
|
current: str | None = None
|
|
for raw in diff.split("\n"):
|
|
if raw.startswith("+++ "):
|
|
name = raw[4:]
|
|
current = name[2:] if name.startswith("b/") else None
|
|
if current is not None and not _lint_path(current):
|
|
current = None
|
|
continue
|
|
if current is None:
|
|
continue
|
|
m = _HUNK_RE.match(raw)
|
|
if m:
|
|
start, count = int(m.group(1)), int(m.group(2) or "1")
|
|
out.setdefault(current, set()).update(range(start, start + count))
|
|
return out
|
|
|
|
|
|
def _read(rel: str, head: str | None) -> str | None:
|
|
if head:
|
|
cmd = ["git", "show", f"{head}:{rel}"]
|
|
r = subprocess.run(cmd, cwd=ROOT, capture_output=True, text=True, encoding="utf-8", errors="replace")
|
|
return r.stdout if r.returncode == 0 else None
|
|
path = ROOT / rel
|
|
return path.read_text(encoding="utf-8-sig", errors="replace") if path.is_file() else None
|
|
|
|
|
|
def run(base: str | None, head: str | None, files: list[str], patterns: list[dict]) -> list[Finding]:
|
|
findings: list[Finding] = []
|
|
if files:
|
|
for f in files:
|
|
path = Path(f)
|
|
text = path.read_text(encoding="utf-8-sig", errors="replace") if path.is_file() else None
|
|
if text is None:
|
|
continue
|
|
resolved = path.resolve()
|
|
rel = str(resolved.relative_to(ROOT)) if resolved.is_relative_to(ROOT) else str(path)
|
|
findings.extend(scan_text(rel, text, patterns))
|
|
return findings
|
|
for rel, lines in sorted(added_lines_vs_base(base or "origin/main", head).items()):
|
|
text = _read(rel, head)
|
|
if text is not None:
|
|
findings.extend(scan_text(rel, text, patterns, lines))
|
|
return findings
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
ap.add_argument("--base", default="origin/main", help="base ref for the added-lines diff")
|
|
ap.add_argument("--head", default=None, help="head ref (default: working tree)")
|
|
ap.add_argument("--files", nargs="*", default=[], help="scan these whole files instead of a diff")
|
|
ap.add_argument("--json", default=None, help="also write findings as JSON to this path")
|
|
args = ap.parse_args(argv)
|
|
|
|
patterns = load_patterns()
|
|
findings = run(args.base, args.head, args.files, patterns)
|
|
if args.json:
|
|
Path(args.json).write_text(json.dumps([asdict(f) for f in findings], indent=2) + "\n", encoding="utf-8")
|
|
if not findings:
|
|
print("profile-scope patterns: 0 findings")
|
|
return 0
|
|
print(f"profile-scope patterns: {len(findings)} finding(s) — ADVISORY, read each against its scope hint "
|
|
f"(root AGENTS.md § Code Shape Rules; scripts/ci/profile_scope_patterns.json)")
|
|
for f in findings:
|
|
print(f"{f.path}:{f.line} {f.pattern_id}/{f.pattern_class} {f.why}")
|
|
print(f" | {f.text}")
|
|
print(f" hint: {f.scope_hint}")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|