#!/usr/bin/env python3 """Pure, standard-library support for implementation-spec review artifacts.""" from __future__ import annotations import argparse, grp, hashlib, json, math, os, pwd, re, stat, sys from typing import NoReturn from pathlib import Path SCHEMA = "gitea-agent.implementation-spec-review-result/v1" TOP = ("schema", "target", "contract_sha256", "verdict", "criteria", "scope", "conclusion") TARGET = ("owner", "repository", "issue", "pull", "head") CRITERION = ("criterion", "contract", "implementation_paths", "test_paths", "evidence", "result", "reason") SCOPE = ("missing", "excess", "spec_conflicts") VERDICTS = {"approve", "request_changes", "blocked_spec"} RESULTS = {"satisfied", "unsatisfied", "indeterminate"} HEX40 = re.compile(r"^[0-9a-f]{40}$") HEX64 = re.compile(r"^[0-9a-f]{64}$") IDENT = re.compile(r"^[^\s/\\\x00<>`=@]+$") CONTROL = re.compile(r"[\x00-\x1f\x7f-\x9f\u2028\u2029]") MARKUP = re.compile(r"[`<>\[\]{}]|(?:^|\s)[#*!|]|[a-z][a-z0-9+.-]*://|www\.", re.I) class ValidationError(ValueError): pass def _fail(msg="invalid review artifact") -> NoReturn: raise ValidationError(msg) def _obj(v, keys, label): if type(v) is not dict or set(v) != set(keys) or len(v) != len(keys): _fail("invalid " + label) return v def _str(v, label, lo=1, hi=4000, human=False): if type(v) is not str or not lo <= len(v) <= hi: _fail("invalid " + label) if CONTROL.search(v) or v != v.rstrip(): _fail("invalid " + label) try: v.encode("utf-8") except UnicodeError: _fail("invalid " + label) if human and (MARKUP.search(v) or "@" in v): _fail("invalid " + label) return v def _array(v, label, hi=512, human=False): if type(v) is not list or len(v) > hi: _fail("invalid " + label) out=[]; seen=set() for x in v: _str(x, label + " item", 1, 4000, human) if x in seen: _fail("duplicate " + label) seen.add(x); out.append(x) return out def loads_json(text): if type(text) is not str: _fail("invalid JSON") def hook(pairs): d={} for k,v in pairs: if k in d: _fail("duplicate JSON key") d[k]=v return d try: return json.loads(text, object_pairs_hook=hook, parse_constant=lambda x: _fail("nonfinite JSON"), parse_float=lambda x: float(x) if math.isfinite(float(x)) else _fail("nonfinite JSON")) except ValidationError: raise except Exception: _fail("invalid JSON") def _path(p): _str(p, "path", 1, 1024) if p.startswith("/") or "\\" in p or "\x00" in p: _fail("invalid path") bits=p.split("/") if any(x in ("", ".", "..") for x in bits): _fail("invalid path") return p def validate_result(result, allowed_paths, sensitive_values=()): _obj(result, TOP, "result schema") if result["schema"] != SCHEMA: _fail("invalid schema") t=_obj(result["target"], TARGET, "target") for k in ("owner", "repository"): _str(t[k], k, 1, 255); if not IDENT.fullmatch(t[k]) or t[k] in (".", ".."): _fail("invalid target") for k in ("issue", "pull"): if type(t[k]) is not int or t[k] < 1: _fail("invalid target") if type(t["head"]) is not str or not HEX40.fullmatch(t["head"]): _fail("invalid target") if type(result["contract_sha256"]) is not str or not HEX64.fullmatch(result["contract_sha256"]): _fail("invalid contract hash") if type(result["verdict"]) is not str or result["verdict"] not in VERDICTS: _fail("invalid verdict") if type(allowed_paths) not in (list, tuple, set, frozenset): _fail("invalid allowed paths") for path in allowed_paths: _path(path) allowed = set(allowed_paths) if len(allowed) != len(allowed_paths): _fail("duplicate allowed path") criteria=result["criteria"] if type(criteria) is not list or not 1 <= len(criteria) <= 512: _fail("invalid criteria") ids=set() for c in criteria: _obj(c, CRITERION, "criterion") _str(c["criterion"], "criterion", 1, 200, True) if c["criterion"] in ids: _fail("duplicate criterion") ids.add(c["criterion"]) for k in ("contract", "evidence", "reason"): _str(c[k], k, 1, 4000, True) for k in ("implementation_paths", "test_paths"): for p in _array(c[k], k): _path(p) if p not in allowed: _fail("path outside allowed set") if type(c["result"]) is not str or c["result"] not in RESULTS: _fail("invalid criterion result") s=_obj(result["scope"], SCOPE, "scope") for k in SCOPE: _array(s[k], k, 512, True) _str(result["conclusion"], "conclusion", 1, 4000, True) human = [result["conclusion"]] + [c[k] for c in criteria for k in ("criterion", "contract", "evidence", "reason")] human += [x for k in SCOPE for x in s[k]] for secret in sensitive_values: if type(secret) is not str: _fail("invalid sensitive values") if secret and any(secret in value for value in human): _fail("sensitive value in human data") states={c["result"] for c in criteria} if result["verdict"] == "approve" and (states != {"satisfied"} or any(s[k] for k in SCOPE)): _fail("verdict contradiction") if result["verdict"] == "request_changes" and not ({"unsatisfied", "indeterminate"} & states): _fail("verdict contradiction") if result["verdict"] == "blocked_spec" and not (s["spec_conflicts"] or "indeterminate" in states): _fail("verdict contradiction") return result def _sha(b): return hashlib.sha256(b).hexdigest() def _human_paths(xs): if not xs: return "없음" # Quote complete list elements only when plain comma-separated prose is ambiguous. unsafe = "<>@`[]{}*|~" if any(", " in x or "; " in x or x == "없음" or '"' in x or any(c in x for c in unsafe) for x in xs): def quoted(value): text = json.dumps(value, ensure_ascii=False) for char in unsafe: text = text.replace(char, "\\u%04x" % ord(char)) return text return ", ".join(quoted(x) for x in xs) return ", ".join(xs) def _parse_list(text): if text == "없음": return [] if not text.startswith('"'): return text.split(", ") decoder = json.JSONDecoder() out = [] while text: try: value, end = decoder.raw_decode(text) except ValueError: _fail("invalid list encoding") if type(value) is not str: _fail("invalid list encoding") out.append(value) text = text[end:] if text: if not text.startswith(", "): _fail("invalid list encoding") text = text[2:] if not text: _fail("invalid list encoding") return out def _parse_evidence(text): # Find the first path/evidence separator outside quoted path elements. quoted = escaped = False for i, char in enumerate(text): if quoted: if escaped: escaped = False elif char == "\\": escaped = True elif char == '"': quoted = False elif char == '"': quoted = True elif text[i:i + 2] == "; ": return _parse_list(text[:i]), text[i + 2:] _fail("invalid criterion block") def _marker(r, digest): t=r["target"] raw="\x00".join([t["owner"],t["repository"],str(t["issue"]),str(t["pull"]),t["head"],r["contract_sha256"],digest]).encode() corr=_sha(raw) return ("" % (t["owner"],t["repository"],t["issue"],t["pull"],t["head"],r["contract_sha256"],digest,corr)) def render_review(result, allowed_paths, sensitive_values=()): r=validate_result(result, allowed_paths, sensitive_values); t=r["target"] lines=["## 구현 명세 적합성 검토", "", f"- Source Issue: #{t['issue']}", f"- Pull Request: #{t['pull']}", f"- Reviewed head: `{t['head']}`", f"- Contract SHA-256: `{r['contract_sha256']}`", f"- Verdict: `{r['verdict']}`", "", "### Acceptance criteria 판정", ""] for c in r["criteria"]: lines += [f"- `{c['criterion']}` — `{c['result']}`", f" - 계약: {c['contract']}", f" - 구현 근거: {_human_paths(c['implementation_paths'])}; {c['evidence']}", f" - 테스트 근거: {_human_paths(c['test_paths'])}", f" - 판정 근거: {c['reason']}", ""] s=r["scope"] lines += ["### 범위 판정", "", f"- 누락: {_human_paths(s['missing'])}", f"- 범위 초과: {_human_paths(s['excess'])}", f"- 명세 모순: {_human_paths(s['spec_conflicts'])}", "", "### 결론", "", r["conclusion"], ""] body="\n".join(lines) return body + "\n" + _marker(r, _sha(body.encode("utf-8"))) + "\n" def parse_review(body, allowed_paths, sensitive_values=()): if type(body) is not str or not body.endswith("\n"): _fail("invalid review body") lines=body.splitlines() if not lines or not lines[-1].startswith(""): _fail("invalid review marker") marker=lines[-1]; m=re.fullmatch(r"", marker) if not m: _fail("invalid review marker") # Parse the stable prose emitted by render_review, then validate by re-rendering. try: if lines[0] != "## 구현 명세 적합성 검토" or lines[8] != "### Acceptance criteria 판정": _fail("invalid review body") issue=int(lines[2][len("- Source Issue: #") :]); pull=int(lines[3][len("- Pull Request: #"):]) head=lines[4][len("- Reviewed head: `"):-1]; contract=lines[5][len("- Contract SHA-256: `"):-1]; verdict=lines[6][len("- Verdict: `"):-1] i=9; criteria=[] while i < len(lines) and lines[i] != "### 범위 판정": if not lines[i]: i+=1; continue cm=re.fullmatch(r"- `([^`]+)` — `([^`]+)`", lines[i]); if not cm: _fail("invalid criterion block") c={"criterion":cm.group(1),"result":cm.group(2)} for key,prefix in (("contract"," - 계약: "),("implementation_paths"," - 구현 근거: "),("test_paths"," - 테스트 근거: "),("reason"," - 판정 근거: ")): i+=1 if i>=len(lines) or not lines[i].startswith(prefix): _fail("invalid criterion block") val=lines[i][len(prefix):] if key == "implementation_paths": c[key], c["evidence"] = _parse_evidence(val) else: c[key] = _parse_list(val) if key.endswith("paths") else val c={"criterion":c["criterion"],"contract":c["contract"],"implementation_paths":c["implementation_paths"],"test_paths":c["test_paths"],"evidence":c["evidence"],"result":c["result"],"reason":c["reason"]} criteria.append(c); i+=1 if i+6 >= len(lines) or lines[i+1] != "": _fail("invalid scope block") scope={}; i += 1 for key,prefix in (("missing","- 누락: "),("excess","- 범위 초과: "),("spec_conflicts","- 명세 모순: ")): i += 1 if not lines[i].startswith(prefix): _fail("invalid scope block") scope[key] = _parse_list(lines[i][len(prefix):]) i+=2 if lines[i] != "### 결론" or lines[i+1] != "": _fail("invalid conclusion") conclusion=lines[i+2] except (IndexError, ValueError): _fail("invalid review body") r={"schema":SCHEMA,"target":{"owner":m.group(1),"repository":m.group(2),"issue":issue,"pull":pull,"head":head},"contract_sha256":contract,"verdict":verdict,"criteria":criteria,"scope":scope,"conclusion":conclusion} validate_result(r, allowed_paths, sensitive_values) canonical=render_review(r, allowed_paths, sensitive_values) if canonical != body or _sha(canonical[:canonical.rfind("