#!/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) def _require_adoption(declaration, revision, stage): if declaration is None: _fail("delegation adoption declaration required") try: repo_root = Path(__file__).resolve().parents[6] if str(repo_root) not in sys.path: sys.path.insert(0, str(repo_root)) from harness.lib.delegation_adoption import AdoptionError, require_adoption except Exception: _fail("delegation adoption unavailable") try: return require_adoption(declaration, expected_source={"kind": "git_tree", "revision": revision}, expected_stage=stage) except AdoptionError as exc: _fail(str(exc)) except Exception: _fail("delegation adoption unavailable") 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=(), *, declaration=None): if type(result) is dict and type(result.get("target")) is dict: _require_adoption(declaration, result["target"].get("head"), "spec-review") 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, declaration={"mode": "none"}) if canonical != body or _sha(canonical[:canonical.rfind("' in line or re.search(r']*>', line): _fail("unsupported classification HTML") visible.append(line) if fence is not None: _fail("unterminated classification fence") return visible def check_ac_classification(body, expected_template_sha256): """Check only canonical section-7 AC classification, not full compliance.""" if type(expected_template_sha256) is not str or not HEX64.fullmatch(expected_template_sha256): _fail("invalid trusted template digest") template = _classification_bytes(Path(__file__).resolve().parents[5] / "docs/requirements/TEMPLATE.md") if hashlib.sha256(template).hexdigest() != expected_template_sha256: _fail("trusted template drift") template_text = template.decode("utf-8") try: template_block = template_text.split("## 7. 인수 기준\n", 1)[1].split("## 8.", 1)[0] classification = template_block.split("**검증 분류**\n", 1)[1].split("**상태 불변조건**", 1)[0] except IndexError: _fail("unsupported trusted template") fields = re.findall(r"^- ([^:]+): \$\{[a-z_]+\}$", classification, flags=re.M) if fields != ["수준", "백엔드", "케이스"]: _fail("unsupported trusted template") lines = _classification_lines(body) section = "## 7. 인수 기준" if lines.count(section) != 1: _fail("ambiguous AC section") start = lines.index(section) + 1 end = next((i for i in range(start, len(lines)) if lines[i].startswith("## ")), len(lines)) if end == len(lines) or lines[end] != "## 8. 오류 및 충돌 계약": _fail("unsupported AC section boundary") blocks, current = [], None for line in lines[start:end]: if line.startswith("### "): match = re.fullmatch(r"### (REQ-[0-9]{3}\.[0-9]{2}-AC[0-9]{2}) — (\S.*)", line) if not match: _fail("unsupported AC heading") current = (match[1], []) blocks.append(current) elif current is None: if line.strip(): _fail("unsupported AC preamble") else: if line.lstrip().startswith("#"): _fail("ambiguous AC heading") current[1].append(line) ids = [item[0] for item in blocks] if not ids or len(ids) != len(set(ids)): _fail("missing or duplicate AC") for _, block in blocks: marker = "**검증 분류**" if block.count(marker) != 1: _fail("missing or duplicate AC classification") pos = block.index(marker) + 1 stop = next((i for i in range(pos, len(block)) if block[i].startswith("**")), len(block)) values = {} for line in block[pos:stop]: if not line.strip(): continue match = re.fullmatch(r"- ([^:]+): (.*)", line) if not match or match[1] not in fields or match[1] in values: _fail("ambiguous AC classification field") value = match[2].strip() if not value or "${" in value: _fail("empty or placeholder AC classification") values[match[1]] = value if set(values) != set(fields): _fail("missing AC classification field") return {"ac_ids": ids, "checked_fields": fields, "read_only": True, "template_sha256": expected_template_sha256, "document_sha256": hashlib.sha256(body.encode("utf-8")).hexdigest()} def _read(path): try: with open(path, encoding="utf-8", newline="") as stream: return stream.read() except (OSError, ValueError, UnicodeError): _fail("input unavailable") def _json_file(path): return loads_json(_read(path)) def _emit_error(exc): sys.stderr.write(json.dumps({"error":"invalid input"}, ensure_ascii=False)+"\n"); return 2 class _Parser(argparse.ArgumentParser): def error(self, message): _fail("invalid command arguments") def main(argv=None): p=_Parser(prog="review_support", add_help=True); sub=p.add_subparsers(dest="cmd",required=True) def common(name): q=sub.add_parser(name); q.add_argument("--result",required=True); q.add_argument("--allowed-paths",required=True); return q common("validate-result"); common("render") q=sub.add_parser("parse"); q.add_argument("--body",required=True); q.add_argument("--allowed-paths",required=True) render_parser = sub.choices["render"] adoption = render_parser.add_mutually_exclusive_group(required=True) adoption.add_argument("--no-delegated-evidence", action="store_true") adoption.add_argument("--delegation-adoption") q=sub.add_parser("check-cache"); q.add_argument("--contract-path",required=True); q.add_argument("--owner",required=True); q.add_argument("--repository",required=True); q.add_argument("--issue",required=True,type=int) q=sub.add_parser("check-pages"); q.add_argument("--pages",required=True); q.add_argument("--key",required=True) q.add_argument("--page-size", type=int, default=50); q.add_argument("--max-pages", type=int, default=10000) q=sub.add_parser("check-ac-classification") q.add_argument("--document", required=True) q.add_argument("--expected-document-sha256", required=True) q.add_argument("--expected-template-sha256", required=True) q=sub.add_parser("check-unchanged"); q.add_argument("--before",required=True); q.add_argument("--after",required=True) q=sub.add_parser("verify-publication"); for n in ("before","after","exact-review","result","allowed-paths","inline-comments"): q.add_argument("--"+n,required=True) q.add_argument("--reviewer-id",required=True,type=int); q.add_argument("--provider-contract") q.add_argument("--expected-pr-url", required=True) try: a=p.parse_args(argv) if a.cmd in ("validate-result","render"): r=_json_file(a.result); paths=_json_file(a.allowed_paths); validate_result(r,paths) if a.cmd == "validate-result": out=r else: if a.no_delegated_evidence: declaration={"mode":"none"} else: repo_root=Path(__file__).resolve().parents[6] if str(repo_root) not in sys.path: sys.path.insert(0,str(repo_root)) from harness.lib.delegation_evidence import _json_file as load_declaration declaration=load_declaration(a.delegation_adoption) out=render_review(r,paths,declaration=declaration) print(json.dumps(out,ensure_ascii=False,sort_keys=True) if a.cmd=="validate-result" else out,end="" if a.cmd=="render" else "\n") elif a.cmd=="parse": print(json.dumps(parse_review(_read(a.body),_json_file(a.allowed_paths)),ensure_ascii=False,sort_keys=True)) elif a.cmd=="check-cache": print(json.dumps(validate_cache_paths(a.contract_path,a.owner,a.repository,a.issue),sort_keys=True)) elif a.cmd=="check-pages": pages=_json_file(a.pages) if type(pages) is not list: _fail("invalid pages") used = [] def f(i): used.append(i) return pages[i - 1] items = collect_pages(f, a.key, a.page_size, a.max_pages) if len(used) != len(pages): _fail("extra saved pages") print(json.dumps(items,ensure_ascii=False,sort_keys=True)) elif a.cmd=="check-ac-classification": raw = _classification_bytes(a.document) if type(a.expected_document_sha256) is not str or not HEX64.fullmatch(a.expected_document_sha256) or hashlib.sha256(raw).hexdigest() != a.expected_document_sha256: _fail("classification document drift") print(json.dumps(check_ac_classification(raw.decode("utf-8"), a.expected_template_sha256), ensure_ascii=False, sort_keys=True)) elif a.cmd=="check-unchanged": print(json.dumps({"unchanged":verify_unchanged(_json_file(a.before),_json_file(a.after))})) else: print(json.dumps(verify_publication(_json_file(a.before),_json_file(a.after),_json_file(a.exact_review),_json_file(a.result),_json_file(a.allowed_paths),a.reviewer_id,_json_file(a.inline_comments),_json_file(a.provider_contract) if a.provider_contract else None, expected_pr_url=a.expected_pr_url),sort_keys=True)) return 0 except (ValidationError, OSError, ValueError, TypeError): return _emit_error(None) if __name__ == "__main__": sys.exit(main())