103 lines
4.7 KiB
Python
103 lines
4.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Downstream-consumers sweep for the code-reviewer graph.
|
|
|
|
Runs only when consumer-facing contract surface changed. Extracts the most
|
|
specific changed contract tokens from the diff deterministically (route path
|
|
literals, proto message/service/rpc names, exported symbols), greps sibling
|
|
checkouts under `consumers_root`, and runs a bounded org code search via gh.
|
|
Hits are reported for the synthesis to raise as 🔴 findings naming the
|
|
consuming repo + file:line. Degrades silently when unconfigured/offline;
|
|
public-library caveats are noted, not guessed at.
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
|
|
def load_state():
|
|
if path := os.environ.get("GRAPH_STATE_FILE"):
|
|
with open(path) as f:
|
|
return json.load(f)
|
|
return json.loads(os.environ.get("GRAPH_STATE", "{}"))
|
|
|
|
|
|
state = load_state()
|
|
proj = os.path.abspath(state.get("project_dir") or ".")
|
|
spec = state.get("resolved_diff_spec") or "worktree"
|
|
consumers_root = os.path.expanduser((state.get("consumers_root") or "").strip())
|
|
github_org = (state.get("github_org") or "").strip()
|
|
|
|
if not state.get("consumer_surface"):
|
|
print(json.dumps({"downstream_notes": "(skipped — no consumer-facing contract surface in the diff)"}))
|
|
raise SystemExit(0)
|
|
|
|
|
|
def diff_text():
|
|
args = {"staged": ["--cached"], "worktree": ["HEAD"], "HEAD~1": ["HEAD~1"]}.get(spec, [spec])
|
|
r = subprocess.run(["git", "-C", proj, "diff", *args], capture_output=True, text=True, timeout=90)
|
|
return r.stdout if r.returncode == 0 else ""
|
|
|
|
|
|
try:
|
|
changed = [
|
|
l[1:] for l in diff_text().splitlines()
|
|
if (l.startswith("+") or l.startswith("-")) and not l.startswith(("+++", "---"))
|
|
]
|
|
text = "\n".join(changed)
|
|
tokens = set()
|
|
tokens.update(re.findall(r'["\'](/[a-z0-9][a-z0-9/_{}.:-]{5,})["\']', text)) # route paths
|
|
tokens.update(re.findall(r"^\s*(?:message|service|rpc)\s+([A-Z][A-Za-z0-9_]{5,})", text, re.M)) # proto names
|
|
tokens.update(re.findall(r"^\s*(?:pub\s+fn|pub\s+struct|func|export\s+(?:function|const|class))\s+([A-Z][A-Za-z0-9_]{5,})", text, re.M))
|
|
tokens = sorted(tokens, key=len, reverse=True)[:5]
|
|
|
|
notes = []
|
|
if not tokens:
|
|
notes.append("(no specific contract tokens extractable from the diff — sweep skipped)")
|
|
else:
|
|
notes.append(f"contract tokens probed: {tokens}")
|
|
if consumers_root and os.path.isdir(consumers_root):
|
|
self_name = os.path.basename(proj)
|
|
for tok in tokens:
|
|
r = subprocess.run(
|
|
["grep", "-rIl", "--exclude-dir=.git", "--exclude-dir=node_modules",
|
|
"--exclude-dir=target", "--exclude-dir=vendor", tok, consumers_root],
|
|
capture_output=True, text=True, timeout=60,
|
|
)
|
|
hits = [
|
|
h for h in r.stdout.splitlines()
|
|
if h.strip() and f"/{self_name}/" not in h and not h.startswith(proj)
|
|
][:4]
|
|
for h in hits:
|
|
notes.append(f"CONSUMER HIT (sibling checkout): {h} references '{tok}' — "
|
|
"a changed/removed contract element with a live consumer is a 🔴 finding")
|
|
elif consumers_root:
|
|
notes.append(f"(consumers_root '{consumers_root}' not found — local probe skipped)")
|
|
else:
|
|
notes.append("(consumers_root unset — local probe skipped)")
|
|
|
|
if github_org and shutil.which("gh"):
|
|
env = dict(os.environ)
|
|
env.pop("CLICOLOR_FORCE", None)
|
|
env.pop("FORCE_COLOR", None)
|
|
env["GH_NO_UPDATE_NOTIFIER"] = "1"
|
|
for tok in tokens:
|
|
r = subprocess.run(
|
|
["gh", "api", "search/code", "-f", f'q=org:{github_org} "{tok}"',
|
|
"--jq", r'.items[:4][] | "\(.repository.full_name): \(.path)"'],
|
|
capture_output=True, text=True, timeout=30, env=env,
|
|
)
|
|
if r.returncode == 0 and r.stdout.strip():
|
|
for line in r.stdout.strip().splitlines():
|
|
notes.append(f"CONSUMER HIT (org search): {line} references '{tok}'")
|
|
elif github_org:
|
|
notes.append("(gh unavailable — org code search skipped)")
|
|
else:
|
|
notes.append("(github_org unset — org code search skipped)")
|
|
notes.append("Public-library caveat: consumers of a PUBLIC library are unenumerable — "
|
|
"for exported-API changes the check degrades to semver/changelog discipline.")
|
|
print(json.dumps({"downstream_notes": "\n".join(notes)}))
|
|
except Exception as e: # noqa: BLE001
|
|
print(json.dumps({"downstream_notes": f"(sweep degraded: {e})"}))
|