feat(spec): add reusable implementation review verification helper

This commit is contained in:
2026-10-03 04:21:15 +00:00
parent 349e322f41
commit 5670738424
3 changed files with 1148 additions and 1 deletions
@@ -187,6 +187,25 @@ Canonical body를 렌더링하기 전에 trusted workflow directory에 JSON obje
- Verdict와 native state 조합은 `approve/APPROVED`, `request_changes/REQUEST_CHANGES`, `blocked_spec/COMMENT`만 허용한다.
- Initial version에서는 inline review comment를 만들지 않고 `comments: []`를 명시한다.
## 반복 기계 검증 helper
반복 실행의 기계 검증은 [scripts/review_support.py](scripts/review_support.py)를 사용한다. Python 표준 라이브러리만 필요하며 다음 호출은 `<harness-root>`에서 `terminal`로 실행한다.
```python
terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py --help")
terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py validate-result --result <run>/result.json --allowed-paths <run>/allowed-paths.json")
terminal(command="python3 harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py render --result <run>/result.json --allowed-paths <run>/allowed-paths.json")
```
- `<run>`은 `<harness-root>/harness/.runtime/implementation-spec-review/<run-id>`의 trusted workflow directory다. `allowed-paths.json`은 main이 authoritative changed-file set과 pinned base/head의 실제 경로에서 만든 JSON string array이며 결과 artifact가 스스로 선언한 allowlist를 신뢰하지 않는다.
- `validate-result`, `render`, `parse`는 기존 strict schema, canonical body, digest·correlation과 round-trip을 검증한다. `parse`에는 `--body`와 `--allowed-paths`를 전달한다. 구분자·markup 때문에 모호한 list 항목만 JSON string quoting/escaping으로 원래 값을 보존하며 구분자·markup이 없는 기존 항목의 본문 형식은 유지한다. 출력은 stdout으로 반환하므로 기존 artifact 보존 규칙에 따라 main이 저장한다.
- `check-cache --contract-path <읽은 repository-cache.md> --owner <owner> --repository <repository> --issue <number>`는 기존 canonical cache의 경로별 permission·symlink만 검사한다. Cache 생성·repair·lock·Git fetch와 bare/remote/ref 확인은 기존 절차에 남는다.
- `collect_pages`는 1부터 시작하는 page 번호로 main의 read-only callback을 호출하고 반환된 `items`, `total_count`, `has_more`를 검증한다. API adapter는 provider의 실제 metadata와 확인된 effective page size를 이 envelope와 `page_size`에 대응하고 null collection은 count와 종료 근거로 empty임이 증명될 때만 전달한다. 저장된 page envelope 배열은 `check-pages --pages <file> --key <id|filename|sha> [--page-size <number>] [--max-pages <number>]`로 검증한다.
- `verify-publication --before <file> --after <file> --exact-review <file> --result <file> --allowed-paths <file> --reviewer-id <id> --inline-comments <file> --expected-pr-url <url> [--provider-contract <file>]`는 single native create의 exact review·collection·timeline과 제한된 field projection을 대조한다. `expected_pr_url`은 snapshot에서 자동 추출하지 않고 main이 canonical authenticated access로 따로 확인한 exact PR URL을 제공하며 host·설치 prefix를 포함해 exact-match한다. Timeline은 ID·type·review ID·author·body·timestamp binding과 기존 event 보존을 검증하며 provider 전체 wire schema 확인은 기존 main 절차에 남는다. Snapshot은 `issue`, `issue_comments`, `pr`, `pr_comments`, `reviews`, `timeline`, `files`를 포함한다. Provider 계약을 생략하면 기존 review 변경을 허용하지 않는다. 현재 projection 계약은 `version.version=1.27.3`, boolean `supersession_same_author_only`·`already_dismissed_review_timestamp_projection`이며 main이 서버 버전별 authoritative 근거를 검증한 뒤 제공한다. `official` 정책은 main이 따로 판정하고 확인된 boolean `expected_official`을 계약에 제공한 경우 helper도 대조한다. 동일 `PENDING` ID submit 복구는 기존 절차로 검증하며 이 helper의 single-create 검증으로 대체하지 않는다.
- `check-unchanged --before <file> --after <file>`는 read-only replay의 exact snapshot 동일성을 확인한다. Helper 성공은 호출자가 실제 write를 하지 않았다는 증명이나 최종 리뷰 완료를 단독으로 의미하지 않는다.
- Helper는 credential을 발견하거나 network·외부 write를 수행하지 않고 acceptance 의미·evidence 유효성·verdict를 결정하지 않는다. 실제 secret 부재는 main이 별도로 확인하며 알려진 민감값은 Python API의 `sensitive_values`로 메모리 안에서만 전달한다. 입력 오류는 nonzero로 중단하고 입력 본문·credential을 error에 출력하지 않는다.
- 회귀 검증은 `terminal(command="PYTHONDONTWRITEBYTECODE=1 python3 -m unittest discover -s harness/tests/spec -p 'test_implementation_spec_review_support.py' -v")`로 실행한다. Schema/body/provider 처리 변경 시 같은 schema의 저장된 실제 wire artifact로 render·parse byte 일치와 publication·read-only replay도 검증한다. Helper 검증을 위해 새 외부 write를 하지 않으며 과거 artifact replay를 fresh authenticated readback으로 보고하지 않는다.
## 절차
1. Exact target identifiers를 검증하고 `operations/repository-cache.md`를 새로 읽는다.
@@ -199,7 +218,7 @@ Canonical body를 렌더링하기 전에 trusted workflow directory에 JSON obje
7. Cache path·ownership·permission·bare·remote를 검증하고 canonical base repository origin에서 prune/fetch한다. Gitea default branch SHA와 local ref를 일치시키고 PR base commit object가 cache에 존재하는지 검증한다.
8. Same-repository PR이면 head object도 cache에서 검증하고 exact base-to-head tree를 Gitea changed-file set·diff와 대조한다. Fork PR이면 target cache에 head object가 있다고 가정하지 않고 authoritative head repository·branch SHA와 Gitea changed-file set·diff를 대조한다. Diff 직렬화의 byte 차이만으로 실패시키지 않고 변경 경로와 exact base/head blob 및 hunk가 동일 변경을 나타내는지 검증한다. 임의 ref, checkout, clone 또는 worktree를 만들지 않는다.
9. Repository lock을 해제하되 Issue lock은 유지한다.
10. Acceptance matrix 작성 전에 기존 evidence의 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
10. Acceptance matrix 작성 전에 기존 evidence의 source revision과 reviewed head 사이 전체 diff·실행 입력 변경을 확인하고, 필수 검증별 재사용 가능 범위와 현재 head에 새로 결합해야 할 근거를 먼저 요약한다. 재사용 여부는 위 Acceptance matrix의 기존 evidence binding 기준으로 판단하며 새 gate나 일괄 재실행 요구를 추가하지 않는다. 재검토에서는 이전 finding별 승인 계약 근거와 최소 보완 요청을 새 구현·evidence의 확인 위치에 대응하고, 해소 또는 잔여 사유를 기존 criterion의 `evidence`와 `reason`에 기록한다. 이미 충족된 검증을 source revision 차이만으로 다시 요구하지 않되, 변경 영향으로 새로 판정할 수 없어진 필수 조건은 그 근거와 최소 보완 범위를 특정한다. 이 초기 요약만으로 검토를 끝내지 않고 Approved Discovery에서 acceptance matrix를 만들고 모든 changed file, 문서, source, test와 exact-head-bound status evidence를 대응시킨다.
11. 독립 code-review worker 결과가 있으면 exact head에 바인딩됐고 authoritative하게 읽을 수 있을 때만 보조 evidence로 사용한다. 그 결과를 spec verdict로 그대로 복사하지 않는다.
12. Verdict 규칙으로 하나의 strict result artifact와 canonical review body를 trusted workflow directory에 생성한다.
13. Exact key/type, duplicate key, marker field, digest, deterministic correlation, criterion cardinality, path binding, verdict/state 조합, credential 부재와 render → parse → render byte equality를 검증한다.
@@ -0,0 +1,666 @@
#!/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 ("<!-- gitea-agent:implementation-spec-review/v1 owner=%s repository=%s issue=%d pr=%d head=%s contract-sha256=%s result-sha256=%s correlation=%s -->" %
(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("<!-- gitea-agent:implementation-spec-review/v1 ") or not lines[-1].endswith(" -->"): _fail("invalid review marker")
marker=lines[-1]; m=re.fullmatch(r"<!-- gitea-agent:implementation-spec-review/v1 owner=([^ ]+) repository=([^ ]+) issue=(\d+) pr=(\d+) head=([0-9a-f]{40}) contract-sha256=([0-9a-f]{64}) result-sha256=([0-9a-f]{64}) correlation=([0-9a-f]{64}) -->", 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("<!--") - 1].encode()) != m.group(7): _fail("review digest mismatch")
if _marker(r,m.group(7)) != marker: _fail("review marker mismatch")
return r
def _lstat(path, kind=None):
try: st=os.lstat(path)
except OSError: _fail("cache path unavailable")
if stat.S_ISLNK(st.st_mode): _fail("cache symlink")
if kind and not kind(st.st_mode): _fail("cache path type")
return st
def _safe_name(x):
if type(x) is not str or not x or x in (".", "..") or "/" in x or "\\" in x or CONTROL.search(x):
_fail("invalid cache identifier")
def _role_group_ok(st, uid):
if not (st.st_mode & stat.S_IWGRP):
return True
try:
group = grp.getgrgid(st.st_gid)
role = pwd.getpwuid(uid)
accounts = pwd.getpwall()
if group.gr_gid != st.st_gid or role.pw_uid != uid or not accounts:
return False
members = set(group.gr_mem)
members.update(account.pw_name for account in accounts if account.pw_gid == st.st_gid)
if role.pw_gid == st.st_gid:
members.add(role.pw_name)
return members == {role.pw_name}
except (KeyError, OSError):
return False
def _optional_stat(path, kind):
try:
os.lstat(path)
except FileNotFoundError:
return None
except OSError:
_fail("cache path unavailable")
return _lstat(path, kind)
def validate_cache_paths(contract_path, owner, repository, issue, uid=None):
"""Inspect existing paths only; acquire locks and verify Git separately."""
_safe_name(owner)
_safe_name(repository)
if type(issue) is not int or issue < 1:
_fail("invalid cache issue")
uid = os.getuid() if uid is None else uid
if type(uid) is not int or uid < 0:
_fail("invalid role UID")
try:
raw = os.fspath(contract_path)
except TypeError:
_fail("invalid cache contract path")
if type(raw) is not str or not raw.startswith("/") or any(x in (".", "..", "") for x in raw.split("/")[1:]):
_fail("invalid cache contract path")
cp = Path(raw)
if tuple(cp.parts[-3:]) != ("harness", "operations", "repository-cache.md"):
_fail("invalid cache contract path")
root = cp.parent.parent.parent
for ancestor in reversed(cp.parents):
_lstat(ancestor, stat.S_ISDIR)
_lstat(cp, stat.S_ISREG)
# NFS permits a different root owner, but never a world-writable root.
if os.lstat(root).st_mode & stat.S_IWOTH:
_fail("invalid harness root")
rs = root / "repositories"
rst = _lstat(rs, stat.S_ISDIR)
if rst.st_uid != uid or rst.st_mode & stat.S_IWOTH or not _role_group_ok(rst, uid):
_fail("invalid repositories directory")
base = rs / owner / repository
for path in (rs / owner, base, base / "locks"):
st = _lstat(path, stat.S_ISDIR)
if st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o700:
_fail("invalid cache directory")
git = base / "repository.git"
gst = _lstat(git, stat.S_ISDIR)
if gst.st_uid != uid or gst.st_mode & stat.S_IWOTH:
_fail("invalid repository cache")
# Bootstrap paths are optional for an already-existing cache.
bootstrap_root = rs / ".locks"
if _optional_stat(bootstrap_root, stat.S_ISDIR) is not None:
for path in (bootstrap_root, bootstrap_root / owner):
st = _optional_stat(path, stat.S_ISDIR)
if st is not None and (st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o700):
_fail("invalid bootstrap directory")
bootstrap = bootstrap_root / owner / (repository + ".bootstrap.lock")
st = _optional_stat(bootstrap, stat.S_ISREG)
if st is not None and (st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o600):
_fail("invalid bootstrap lock")
out = {"root": str(root), "repositories": str(rs), "owner": str(rs / owner),
"repository": str(base), "repository_git": str(git), "locks": str(base / "locks")}
for name in (f"issue-{issue}.lock", "repository.lock"):
path = base / "locks" / name
st = _optional_stat(path, stat.S_ISREG)
if st is not None:
if st.st_uid != uid or stat.S_IMODE(st.st_mode) != 0o600:
_fail("invalid lock file")
out[name] = str(path)
return out
def collect_pages(fetch_page, key, page_size=50, max_pages=10000):
"""Consume provider-verified envelopes with one-based page numbers."""
if key not in ("id", "filename", "sha") or type(key) is not str:
_fail("invalid identity key")
if type(page_size) is not int or page_size < 1 or type(max_pages) is not int or max_pages < 1:
_fail("invalid pagination limits")
all_items, seen, total = [], set(), None
for page in range(1, max_pages + 1):
try:
env = fetch_page(page)
except Exception:
_fail("page read failed")
if type(env) is not dict or "items" not in env or set(env) - {"items", "total_count", "has_more"}:
_fail("invalid page envelope")
declared, more = env.get("total_count"), env.get("has_more")
if declared is not None and (type(declared) is not int or declared < 0):
_fail("invalid page count")
if more is not None and type(more) is not bool:
_fail("invalid page metadata")
if declared is not None:
if total is not None and total != declared:
_fail("conflicting page count")
total = declared
items = env["items"]
if items is None and declared == 0 and more is False:
items = []
if type(items) is not list or len(items) > page_size:
_fail("invalid page items")
for item in items:
if type(item) is not dict or key not in item:
_fail("invalid page identity")
ident = item[key]
if key == "id":
_positive_id(ident)
elif key == "filename":
_path(ident)
elif type(ident) is not str or not HEX40.fullmatch(ident):
_fail("invalid page identity")
if ident in seen:
_fail("duplicate page identity")
seen.add(ident)
all_items.append(item)
if total is not None and len(all_items) > total:
_fail("page count exceeded")
if more is True:
if len(items) != page_size or (total is not None and len(all_items) >= total):
_fail("truncated or inconsistent continuation")
continue
done = more is False or (more is None and (
(total is not None and len(all_items) == total) or len(items) < page_size))
if done:
if total is not None and len(all_items) != total:
_fail("truncated pages")
return all_items
_fail("pagination limit reached")
def _json_bytes(value):
def check(item):
if type(item) is dict:
if any(type(key) is not str for key in item):
_fail("invalid JSON object")
for child in item.values():
check(child)
elif type(item) is list:
for child in item:
check(child)
elif item is not None and type(item) not in (str, int, float, bool):
_fail("invalid JSON value")
check(value)
try:
return json.dumps(value, sort_keys=True, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
except (TypeError, ValueError, UnicodeError):
_fail("invalid JSON value")
def _same(a, b):
return _json_bytes(a) == _json_bytes(b)
def verify_unchanged(before, after):
if not _same(before, after):
_fail("snapshot changed")
return True
def _positive_id(value):
if type(value) is not int or value < 1:
_fail("invalid identity")
return value
def _user_id(record):
if type(record) is not dict or type(record.get("user")) is not dict:
_fail("invalid author identity")
return _positive_id(record["user"].get("id"))
def _index(collection, key="id"):
if type(collection) is not list:
_fail("invalid collection")
out = {}
for item in collection:
if type(item) is not dict or key not in item:
_fail("invalid collection identity")
ident = item[key]
if key == "id":
_positive_id(ident)
elif key == "filename":
_path(ident)
_str(item.get("status"), "file status", 1, 100)
if ident in out:
_fail("duplicate collection identity")
out[ident] = item
return out
def _timestamp(value):
from datetime import datetime
if type(value) is not str or not re.fullmatch(
r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", value):
_fail("invalid review timestamp")
try:
datetime.fromisoformat(value)
except ValueError:
_fail("invalid review timestamp")
return value
def _without(value, fields):
return {key: child for key, child in value.items() if key not in fields}
def _target_snapshot(snapshot, target):
fields = ("issue", "issue_comments", "pr", "pr_comments", "reviews", "timeline", "files")
_obj(snapshot, fields, "publication snapshot")
issue, pr = snapshot["issue"], snapshot["pr"]
if type(issue) is not dict or type(pr) is not dict:
_fail("invalid source snapshot")
if _positive_id(issue.get("number")) != target["issue"] or _positive_id(pr.get("number")) != target["pull"]:
_fail("snapshot target mismatch")
fullname = target["owner"] + "/" + target["repository"]
if type(issue.get("repository")) is not dict or issue["repository"].get("full_name") != fullname:
_fail("snapshot repository mismatch")
if type(pr.get("base")) is not dict or type(pr["base"].get("repo")) is not dict or pr["base"]["repo"].get("full_name") != fullname:
_fail("snapshot repository mismatch")
if type(pr.get("head")) is not dict or pr["head"].get("sha") != target["head"]:
_fail("snapshot head mismatch")
if pr.get("state") != "open" or pr.get("merged") is not False:
_fail("snapshot PR state mismatch")
from urllib.parse import urlsplit
url = pr.get("html_url")
if type(url) is not str:
_fail("invalid PR URL")
try:
parts = urlsplit(url)
except ValueError:
_fail("invalid PR URL")
if parts.scheme not in ("https", "http") or not parts.hostname or parts.username or parts.password or parts.query or parts.fragment or not parts.path.endswith("/" + fullname + "/pulls/" + str(target["pull"])):
_fail("snapshot PR URL mismatch")
for key in ("issue_comments", "pr_comments", "reviews", "timeline"):
_index(snapshot[key])
_index(snapshot["files"], "filename")
return url
def _projection_contract(contract):
if contract is None:
return False, False, None
if type(contract) is not dict or type(contract.get("version")) is not dict or contract["version"].get("version") != "1.27.3":
_fail("unsupported provider projection contract")
flags = []
for key in ("supersession_same_author_only", "already_dismissed_review_timestamp_projection"):
value = contract.get(key, False)
if type(value) is not bool:
_fail("invalid provider projection contract")
flags.append(value)
official = contract.get("expected_official")
if official is not None and type(official) is not bool:
_fail("invalid provider review policy")
return flags[0], flags[1], official
def verify_publication(before, after, exact_review, result, allowed_paths, reviewer_id, inline_comments, provider_contract=None, *, expected_pr_url):
"""Verify saved readbacks of one native create; never authenticate or publish."""
validate_result(result, allowed_paths)
_positive_id(reviewer_id)
if type(inline_comments) is not list or inline_comments:
_fail("inline comments are not supported")
target = result["target"]
_str(expected_pr_url, "canonical PR URL", 1, 4000)
url = _target_snapshot(before, target)
if url != expected_pr_url or _target_snapshot(after, target) != expected_pr_url:
_fail("snapshot PR URL mismatch")
if _user_id(before["pr"]) == reviewer_id:
_fail("reviewer is PR author")
supersede, retouch, expected_official = _projection_contract(provider_contract)
old_reviews, reviews = _index(before["reviews"]), _index(after["reviews"])
if type(exact_review) is not dict:
_fail("invalid exact review")
rid = _positive_id(exact_review.get("id"))
if set(reviews) - set(old_reviews) != {rid} or set(old_reviews) - set(reviews):
_fail("review cardinality mismatch")
if [x for x in reviews if x != rid] != list(old_reviews):
_fail("previous review order changed")
er = reviews[rid]
if not _same(er, exact_review):
_fail("exact review readback mismatch")
expected = {"approve": "APPROVED", "request_changes": "REQUEST_CHANGES", "blocked_spec": "COMMENT"}[result["verdict"]]
if er.get("body") != render_review(result, allowed_paths) or er.get("commit_id") != target["head"] or er.get("state") != expected:
_fail("review binding mismatch")
if _user_id(er) != reviewer_id or er.get("pull_request_url") != url:
_fail("review identity mismatch")
if er.get("stale") is not False or er.get("dismissed") is not False or type(er.get("official")) is not bool:
_fail("review status mismatch")
if type(er.get("comments_count")) is not int or er["comments_count"] != 0:
_fail("review comment count mismatch")
if expected_official is not None and er["official"] != expected_official:
_fail("review policy mismatch")
submitted = _timestamp(er.get("submitted_at"))
_timestamp(er.get("updated_at"))
old_events, events = _index(before["timeline"]), _index(after["timeline"])
additions = set(events) - set(old_events)
if len(additions) != 1 or set(old_events) - set(events) or not _same(after["timeline"][:len(before["timeline"])], before["timeline"]):
_fail("timeline cardinality or preservation mismatch")
event = after["timeline"][-1]
if event["id"] not in additions or _positive_id(event.get("review_id")) != rid or event.get("type") != "review":
_fail("timeline review binding mismatch")
if _user_id(event) != reviewer_id or event.get("body") != er["body"] or event.get("pull_request_url") != url:
_fail("timeline author or body mismatch")
created = _timestamp(event.get("created_at"))
event_updated = _timestamp(event.get("updated_at"))
# Bind the event by exact record ID/author/body, not coincident wall clocks.
causal_times = {submitted, created, event_updated}
for section in ("issue", "pr"):
b, a = before[section], after[section]
permitted = {"updated_at"} if section == "issue" else {"updated_at", "review_comments", "requested_reviewers"}
if not _same(_without(b, permitted), _without(a, permitted)) or set(b) != set(a):
_fail("source or PR drift")
if not _same(b.get("updated_at"), a.get("updated_at")) and a.get("updated_at") not in causal_times:
_fail("timestamp projection mismatch")
bpr, apr = before["pr"], after["pr"]
old_count, count = bpr.get("review_comments"), apr.get("review_comments")
if type(old_count) is not int or old_count < 0 or type(count) is not int or count != old_count + 1:
_fail("review count projection mismatch")
old_requested = _index(bpr.get("requested_reviewers"))
requested = _index(apr.get("requested_reviewers"))
if not _same(bpr["requested_reviewers"], apr["requested_reviewers"]):
if reviewer_id in old_requested or set(requested) - set(old_requested) != {reviewer_id} or set(old_requested) - set(requested):
_fail("requested reviewers projection mismatch")
if not _same([x for x in apr["requested_reviewers"] if x["id"] != reviewer_id], bpr["requested_reviewers"]):
_fail("previous requested reviewers changed")
for collection in ("issue_comments", "pr_comments"):
if not _same(before[collection], after[collection]):
_fail("comment collection changed")
if not _same(_index(before["files"], "filename"), _index(after["files"], "filename")):
_fail("changed file set drift")
projected = []
for old_id, old in old_reviews.items():
current = reviews[old_id]
if _same(old, current):
continue
if _user_id(old) != reviewer_id or old.get("pull_request_url") != url or old.get("state") not in ("APPROVED", "REQUEST_CHANGES"):
_fail("previous review drift")
fields = {"official", "dismissed", "updated_at"}
if set(old) != set(current) or not _same(_without(old, fields), _without(current, fields)) or current.get("updated_at") not in causal_times:
_fail("previous review drift")
superseded = supersede and old.get("official") is True and old.get("dismissed") is False
retouched = retouch and old.get("official") is False and old.get("dismissed") is True
if not (superseded or retouched) or current.get("official") is not False or current.get("dismissed") is not True:
_fail("previous review projection not authorized")
projected.append(old_id)
return {"review_id": rid, "state": expected, "review_records_added": 1,
"timeline_events_added": 1, "projected_review_ids": projected, "read_only": True}
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)
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-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); out=r if a.cmd=="validate-result" else render_review(r,paths); 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-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())
@@ -0,0 +1,462 @@
import copy
import importlib.util
import json
import os
import stat
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
MODPATH = ROOT / "harness/agents/spec/skills/gitea-implementation-spec-review/scripts/review_support.py"
spec = importlib.util.spec_from_file_location("review_support", MODPATH)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
def result(verdict="approve"):
return {
"schema": "gitea-agent.implementation-spec-review-result/v1",
"target": {"owner": "acme", "repository": "repo", "issue": 3, "pull": 7,
"head": "a" * 40},
"contract_sha256": "b" * 64,
"verdict": verdict,
"criteria": [{"criterion": "REQ-1", "contract": "계약", "implementation_paths": ["src/a.py"],
"test_paths": ["tests/test_a.py"], "evidence": "증거", "result": "satisfied",
"reason": "충족"}],
"scope": {"missing": [], "excess": [], "spec_conflicts": []},
"conclusion": "모든 기준을 충족했다.",
}
def publication_fixture():
r = result(); paths = {"src/a.py", "tests/test_a.py"}
body = mod.render_review(r, paths)
url = "https://example.test/acme/repo/pulls/7"
user = {"id": 42, "login": "spec-agent"}
review = {"id": 11, "body": body, "commit_id": r["target"]["head"], "state": "APPROVED",
"user": user, "official": True, "stale": False, "dismissed": False,
"comments_count": 0, "submitted_at": "2026-10-03T01:24:00Z",
"updated_at": "2026-10-03T01:24:00Z", "pull_request_url": url}
before = {"issue": {"id": 3, "number": 3, "repository": {"full_name": "acme/repo"},
"updated_at": "2026-10-03T00:00:00Z", "title": "contract"},
"issue_comments": [], "pr": {"id": 7, "number": 7, "html_url": url,
"url": url, "base": {"repo": {"full_name": "acme/repo"}, "sha": "c" * 40},
"head": {"sha": "a" * 40}, "user": {"id": 99}, "state": "open", "merged": False,
"review_comments": 0, "updated_at": "2026-10-03T00:00:00Z", "requested_reviewers": []},
"pr_comments": [], "reviews": [], "timeline": [],
"files": [{"filename": "src/a.py", "status": "modified", "additions": 1}]}
after = copy.deepcopy(before); after["reviews"] = [review]
after["pr"]["updated_at"] = review["submitted_at"]; after["pr"]["review_comments"] = 1
after["timeline"] = [{"id": 101, "review_id": 11, "user": user, "body": body,
"type": "review", "created_at": review["submitted_at"],
"updated_at": review["submitted_at"], "pull_request_url": url}]
return before, after, review, r, paths
def verify_fixture(before, after, review, r, paths, contract=None):
return mod.verify_publication(before, after, review, r, paths, 42, [], contract,
expected_pr_url="https://example.test/acme/repo/pulls/7")
class SupportTests(unittest.TestCase):
def test_loads_json_rejects_duplicate_and_nonfinite(self):
with self.assertRaises(mod.ValidationError):
mod.loads_json('{"a":1,"a":2}')
with self.assertRaises(mod.ValidationError):
mod.loads_json('{"a":NaN}')
def test_validate_and_render_roundtrip(self):
r = result()
self.assertEqual(mod.validate_result(r, {"src/a.py", "tests/test_a.py"}), r)
body = mod.render_review(r, {"src/a.py", "tests/test_a.py"})
parsed = mod.parse_review(body, {"src/a.py", "tests/test_a.py"})
self.assertEqual(parsed, r)
self.assertEqual(mod.render_review(parsed, {"src/a.py", "tests/test_a.py"}), body)
def test_validation_rejects_bool_path_traversal_bad_verdict(self):
r = result()
r["target"]["issue"] = True
with self.assertRaises(mod.ValidationError):
mod.validate_result(r, {"src/a.py"})
r = result(); r["criteria"][0]["implementation_paths"] = ["../x"]
with self.assertRaises(mod.ValidationError):
mod.validate_result(r, {"../x"})
r = result("request_changes")
with self.assertRaises(mod.ValidationError):
mod.validate_result(r, {"src/a.py", "tests/test_a.py"})
def test_collect_pages_requires_identity_and_reads_all_pages(self):
pages = [[{"id": 1}, {"id": 2}], [{"id": 3}]]
def fetch(n):
return {"items": pages[n - 1], "total_count": 3, "has_more": n == 1}
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), pages[0] + pages[1])
def bad(n):
return {"items": [{"id": 1}], "total_count": 2, "has_more": True}
with self.assertRaises(mod.ValidationError):
mod.collect_pages(bad, "id", max_pages=1)
def test_verify_unchanged(self):
self.assertTrue(mod.verify_unchanged({"a": [1]}, {"a": [1]}))
with self.assertRaises(mod.ValidationError):
mod.verify_unchanged({"a": 1}, {"a": 2})
def test_cache_validation_and_rejects_symlink(self):
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
contract = ops / "repository-cache.md"; contract.write_text("cache", encoding="utf-8")
rs = root / "repositories"; owner = rs / "acme"; repo = owner / "repo"
(repo / "locks").mkdir(parents=True)
(repo / "repository.git").mkdir()
for p in (rs, owner, repo, repo / "locks"): os.chmod(p, 0o700)
info = mod.validate_cache_paths(contract, "acme", "repo", 3)
self.assertTrue(info["repository_git"].endswith("repository.git"))
(repo / "locks" / "issue-3.lock").symlink_to(contract)
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(contract, "acme", "repo", 3)
def test_publication_accepts_one_exact_review_and_rejects_competing_event(self):
before, after, review, r, paths = publication_fixture()
self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11)
bad = copy.deepcopy(after)
bad["timeline"].append({"id": 102, "review_id": 12, "user": {"id": 42}, "body": review["body"]})
with self.assertRaises(mod.ValidationError):
verify_fixture(before, bad, review, r, paths)
def test_cli_check_pages_and_unchanged(self):
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
td = Path(td); env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
pages = td / "pages.json"; pages.write_text(json.dumps([
{"items": [{"id": 1}], "total_count": 1, "has_more": False}],), encoding="utf-8")
base = [sys.executable, str(MODPATH)]
p = subprocess.run(base + ["check-pages", "--pages", str(pages), "--key", "id"], capture_output=True, text=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr)
b = td / "before.json"; a = td / "after.json"
b.write_text('{"x":1}', encoding="utf-8"); a.write_text('{"x":1}', encoding="utf-8")
p = subprocess.run(base + ["check-unchanged", "--before", str(b), "--after", str(a)], capture_output=True, text=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr); self.assertIn('"unchanged": true', p.stdout)
def test_cli_validate_render_parse(self):
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
td = Path(td); rf = td / "r.json"; af = td / "a.json"; bf = td / "b.md"
rf.write_text(json.dumps(result(), ensure_ascii=False), encoding="utf-8")
af.write_text(json.dumps(["src/a.py", "tests/test_a.py"]), encoding="utf-8")
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
base = [sys.executable, str(MODPATH)]
p = subprocess.run(base + ["validate-result", "--result", str(rf), "--allowed-paths", str(af)],
text=True, capture_output=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr)
p = subprocess.run(base + ["render", "--result", str(rf), "--allowed-paths", str(af)],
text=True, capture_output=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr); bf.write_text(p.stdout, encoding="utf-8")
p = subprocess.run(base + ["parse", "--body", str(bf), "--allowed-paths", str(af)],
text=True, capture_output=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr)
self.assertEqual(json.loads(p.stdout), result())
def test_result_codec_preserves_existing_native_format(self):
r = result()
paths = {"src/a.py", "tests/test_a.py"}
body = mod.render_review(r, paths)
self.assertIn(" - 구현 근거: src/a.py; 증거\n", body)
self.assertIn("모든 기준을 충족했다.\n\n<!--", body)
self.assertEqual(mod.parse_review(body, paths), r)
unordered = json.loads(json.dumps(r, sort_keys=True))
self.assertEqual(mod.render_review(unordered, paths), body)
def test_strict_codec_rejects_unrenderable_and_sensitive_human_values(self):
paths = {"src/a.py", "tests/test_a.py"}
for field, value in (("verdict", []), ("conclusion", "trailing "),
("conclusion", "line\u2028break"), ("conclusion", "ftp://example.test")):
r = result(); r[field] = value
with self.subTest(field=field, value=value), self.assertRaises(mod.ValidationError):
mod.validate_result(r, paths)
for field in ("criterion", "contract", "evidence", "reason"):
r = result(); r["criteria"][0][field] = "secretvalue"
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
r = result(); r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["secretvalue"]
with self.assertRaises(mod.ValidationError):
mod.validate_result(r, paths, sensitive_values=("secretvalue",))
with self.assertRaises(mod.ValidationError):
mod.loads_json('{"n":1e999}')
with self.assertRaises(mod.ValidationError):
mod.validate_result(result(), "src/a.py")
def test_parser_rejects_forged_marker_and_body_drift(self):
paths = {"src/a.py", "tests/test_a.py"}
body = mod.render_review(result(), paths)
for bad in (body.replace("issue=3", "issue=4"), body.replace("증거", "다른 증거"),
body.replace("\n", "\r\n"), body + "\n", body.replace("correlation=", "other=")):
with self.subTest(body=bad[-100:]), self.assertRaises(mod.ValidationError):
mod.parse_review(bad, paths)
def test_codec_roundtrips_delimiter_bearing_paths_and_prose(self):
r = result(); r["criteria"][0]["implementation_paths"] = ["src/a, b.py", "src/c; d.py"]
r["criteria"][0]["evidence"] = "확인; 모두 — 일치"
r["verdict"] = "blocked_spec"; r["scope"]["spec_conflicts"] = ["하나, 둘", "세 가지"]
paths = {"src/a, b.py", "src/c; d.py", "tests/test_a.py"}
self.assertEqual(mod.parse_review(mod.render_review(r, paths), paths), r)
def test_cache_enforces_root_boundary_uid_and_dangling_locks(self):
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
contract = ops / "repository-cache.md"; contract.write_text("cache", encoding="utf-8")
rs = root / "repositories"; owner = rs / "acme"; repo = owner / "repo"
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
for path in (rs, owner, repo, repo / "locks"):
path.chmod(0o700)
os.chmod(root, 0o777)
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(contract, "acme", "repo", 3)
os.chmod(root, 0o700)
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(contract, "acme", "repo", 3, uid=os.getuid() + 10000)
(repo / "locks" / "issue-3.lock").symlink_to(root / "missing")
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(contract, "acme", "repo", 3)
(repo / "locks" / "issue-3.lock").unlink()
alias = root / "alias"; alias.symlink_to(ops.parent, target_is_directory=True)
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(alias / "operations" / "repository-cache.md", "acme", "repo", 3)
def test_cache_group_membership_includes_other_primary_accounts(self):
from types import SimpleNamespace
from unittest.mock import patch
role = SimpleNamespace(pw_name="role", pw_uid=os.getuid(), pw_gid=111)
other = SimpleNamespace(pw_name="other", pw_uid=os.getuid() + 1, pw_gid=111)
group = SimpleNamespace(gr_gid=111, gr_mem=["role"])
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
root = Path(td); ops = root / "harness" / "operations"; ops.mkdir(parents=True)
contract = ops / "repository-cache.md"; contract.write_text("cache")
rs = root / "repositories"; repo = rs / "acme" / "repo"
(repo / "locks").mkdir(parents=True); (repo / "repository.git").mkdir()
for path in (rs / "acme", repo, repo / "locks"):
path.chmod(0o700)
rs.chmod(0o770)
role.pw_gid = other.pw_gid = group.gr_gid = rs.stat().st_gid
with patch.object(mod.grp, "getgrgid", return_value=group), \
patch.object(mod.pwd, "getpwuid", return_value=role), \
patch.object(mod.pwd, "getpwall", return_value=[role, other]):
with self.assertRaises(mod.ValidationError):
mod.validate_cache_paths(contract, "acme", "repo", 3)
def test_publication_accepts_gitea_nested_identity(self):
before, after, review, r, paths = publication_fixture()
self.assertEqual(verify_fixture(before, after, review, r, paths)["review_id"], 11)
def test_publication_requires_exact_readback_not_only_review_id(self):
before, after, review, r, paths = publication_fixture()
for field, value in (("body", "changed"), ("official", False), ("submitted_at", "bad")):
exact = copy.deepcopy(review); exact[field] = value
with self.subTest(field=field), self.assertRaises(mod.ValidationError):
verify_fixture(before, after, exact, r, paths)
def test_publication_rejects_unrelated_drift_and_bad_projections(self):
before, after, review, r, paths = publication_fixture()
cases = []
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("review_comments", 4),
("requested_reviewers", [{"id": 43}]), ("head", {"sha": "d" * 40})):
bad = copy.deepcopy(after); bad["pr"][field] = value; cases.append(bad)
for field, value in (("updated_at", "2026-10-03T10:00:00Z"), ("title", "changed")):
bad = copy.deepcopy(after); bad["issue"][field] = value; cases.append(bad)
bad = copy.deepcopy(after); bad["reviews"][0]["stale"] = 0; cases.append(bad)
bad = copy.deepcopy(after); bad["reviews"][0]["state"] = "PENDING"; cases.append(bad)
bad = copy.deepcopy(after); bad["reviews"].append(copy.deepcopy(review)); cases.append(bad)
bad = copy.deepcopy(after); bad["timeline"][0]["type"] = "comment"; cases.append(bad)
bad = copy.deepcopy(after); bad["timeline"][0]["review_id"] = True; cases.append(bad)
for bad in cases:
with self.subTest(snapshot=cases.index(bad)), self.assertRaises(mod.ValidationError):
verify_fixture(before, bad, bad["reviews"][0], r, paths)
def test_publication_binds_snapshot_repository_issue_and_head(self):
before, after, review, r, paths = publication_fixture()
for section, field, value in (("issue", "number", 4), ("issue", "repository", {"full_name": "wrong/repo"}),
("pr", "head", {"sha": "d" * 40}), ("pr", "number", 8)):
b = copy.deepcopy(before); a = copy.deepcopy(after)
b[section][field] = a[section][field] = value
with self.subTest(section=section, field=field), self.assertRaises(mod.ValidationError):
verify_fixture(b, a, review, r, paths)
def test_publication_preserves_previous_reviews_and_bounds_supersession(self):
before, after, review, r, paths = publication_fixture()
old = copy.deepcopy(review); old.update(id=10, body="previous", commit_id="d" * 40,
state="REQUEST_CHANGES", updated_at="2026-10-03T00:00:00Z")
before["reviews"] = [old]; after["reviews"].insert(0, copy.deepcopy(old))
verify_fixture(before, after, review, r, paths)
after["reviews"][0].update(official=False, dismissed=True, updated_at=review["submitted_at"])
with self.assertRaises(mod.ValidationError):
verify_fixture(before, after, review, r, paths)
contract = {"version": {"version": "1.27.3"}, "supersession_same_author_only": True,
"already_dismissed_review_timestamp_projection": True}
verify_fixture(before, after, review, r, paths, contract)
for mutation in ("body", "user", "missing"):
a = copy.deepcopy(after)
if mutation == "missing": a["reviews"].pop(0)
elif mutation == "user": a["reviews"][0]["user"] = {"id": 43}
else: a["reviews"][0]["body"] = "tampered"
with self.subTest(mutation=mutation), self.assertRaises(mod.ValidationError):
verify_fixture(before, a, review, r, paths, contract)
old["official"] = False; old["dismissed"] = True
after["reviews"][0] = copy.deepcopy(old); after["reviews"][0]["updated_at"] = review["submitted_at"]
verify_fixture(before, after, review, r, paths, contract)
contract["already_dismissed_review_timestamp_projection"] = False
with self.assertRaises(mod.ValidationError):
verify_fixture(before, after, review, r, paths, contract)
def test_publication_preserves_collection_identity_and_normalizes_only_files_order(self):
before, after, review, r, paths = publication_fixture()
second = {"filename": "tests/test_a.py", "status": "added", "additions": 2}
before["files"].append(second); after["files"].insert(0, copy.deepcopy(second))
verify_fixture(before, after, review, r, paths)
after["files"].append(copy.deepcopy(second))
with self.assertRaises(mod.ValidationError):
verify_fixture(before, after, review, r, paths)
def test_read_only_replay_uses_recursive_exact_json_types(self):
with self.assertRaises(mod.ValidationError):
mod.verify_unchanged({"nested": [True]}, {"nested": [1]})
def test_pagination_is_one_based_and_rejects_short_continuations(self):
calls = []
pages = [{"items": [{"id": 1}, {"id": 2}], "total_count": 3, "has_more": True},
{"items": [{"id": 3}], "total_count": 3, "has_more": False}]
def fetch(page):
calls.append(page)
return pages[page - 1]
self.assertEqual(mod.collect_pages(fetch, "id", page_size=2), [{"id": 1}, {"id": 2}, {"id": 3}])
self.assertEqual(calls, [1, 2])
with self.assertRaises(mod.ValidationError):
mod.collect_pages(lambda _: {"items": [{"id": 1}], "total_count": 3, "has_more": True}, "id", page_size=2)
def test_pagination_validates_all_declared_metadata_and_identity_types(self):
bad_pages = [
{"items": [{"id": 1}], "total_count": 0, "has_more": True},
{"items": [{"id": 1}, {"id": 2}], "total_count": 2, "has_more": True},
{"items": [{"id": []}], "has_more": False},
{"items": [{"id": 1.5}], "has_more": False},
{"items": [{"id": 0}], "has_more": False},
{"items": [{"id": True}], "has_more": False},
{"items": [{"id": 1}, {"id": 1}], "has_more": False},
{"items": None, "has_more": False},
{"items": [], "has_more": True},
]
for page in bad_pages:
with self.subTest(page=bad_pages.index(page)), self.assertRaises(mod.ValidationError):
mod.collect_pages(lambda _: page, "id", page_size=2, max_pages=2)
pages = [{"items": [{"id": 1}], "total_count": 2, "has_more": True},
{"items": [{"id": 2}], "total_count": 3, "has_more": False}]
with self.assertRaises(mod.ValidationError):
mod.collect_pages(lambda i: pages[i - 1], "id", page_size=1)
self.assertEqual(mod.collect_pages(lambda _: {"items": None, "total_count": 0, "has_more": False}, "id"), [])
def test_cli_errors_are_sanitized_without_tracebacks_or_echoed_values(self):
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
base = [sys.executable, str(MODPATH)]
for args in (["unknown-secret-value"], ["check-cache", "--issue", "secret-value"],
["check-unchanged", "--before", "secret-value", "--after", "missing"]):
p = subprocess.run(base + args, capture_output=True, text=True, env=env)
self.assertNotEqual(p.returncode, 0)
self.assertNotIn("secret-value", p.stderr + p.stdout)
self.assertNotIn("Traceback", p.stderr + p.stdout)
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
path = Path(td) / "pages.json"
path.write_text(json.dumps([{ "items": [{"id": 1}], "has_more": True, "total_count": 2 }]))
p = subprocess.run(base + ["check-pages", "--pages", str(path), "--key", "id"],
capture_output=True, text=True, env=env)
self.assertNotEqual(p.returncode, 0)
self.assertNotIn("Traceback", p.stderr)
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
def test_target_identifiers_preserve_dots_but_reject_marker_breaking_whitespace(self):
r = result(); paths = {"src/a.py", "tests/test_a.py"}
r["target"].update(owner="acme.name", repository="repo.name")
body = mod.render_review(r, paths)
self.assertEqual(mod.parse_review(body, paths), r)
r["target"]["owner"] = "acme name"
with self.assertRaises(mod.ValidationError):
mod.validate_result(r, paths)
def test_rendered_paths_cannot_inject_markdown_html_or_mentions(self):
r = result(); path = "src/<script>@user`[link].py"
r["criteria"][0]["implementation_paths"] = [path]
paths = {path, "tests/test_a.py"}
body = mod.render_review(r, paths)
self.assertNotIn("<script>", body)
self.assertNotIn("@user", body)
self.assertNotIn("`[link]", body)
self.assertEqual(mod.parse_review(body, paths), r)
def test_strict_types_limits_and_duplicate_nested_keys(self):
paths = {"src/a.py", "tests/test_a.py"}
for bad_json in ('{"outer":{"x":1,"x":2}}', '{"x":Infinity}', '{"x":-Infinity}'):
with self.subTest(bad_json=bad_json), self.assertRaises(mod.ValidationError):
mod.loads_json(bad_json)
cases = []
r = result(); r["target"]["pull"] = False; cases.append(r)
r = result(); r["criteria"].append(copy.deepcopy(r["criteria"][0])); cases.append(r)
r = result(); r["criteria"][0]["contract"] = "a" * 4001; cases.append(r)
r = result(); r["criteria"][0]["criterion"] = "a" * 201; cases.append(r)
r = result(); r["criteria"][0]["result"] = {}; cases.append(r)
r = result(); r["criteria"][0]["test_paths"] *= 2; cases.append(r)
r = result(); r["criteria"][0]["test_paths"] = ["/absolute"]; cases.append(r)
r = result(); r["criteria"][0]["test_paths"] = ["tests/../file.py"]; cases.append(r)
r = result(); r["extra"] = None; cases.append(r)
r = result(); del r["scope"]; cases.append(r)
for value in cases:
with self.subTest(case=cases.index(value)), self.assertRaises(mod.ValidationError):
mod.validate_result(value, paths)
def test_cli_verify_publication_and_rejects_raw_crlf_body(self):
before, after, review, r, paths = publication_fixture()
env = dict(os.environ, PYTHONDONTWRITEBYTECODE="1")
with tempfile.TemporaryDirectory(dir="/opt/data/cache/scratch") as td:
root = Path(td)
for name, value in (("before", before), ("after", after), ("exact", review),
("result", r), ("paths", sorted(paths)), ("inline", [])):
(root / (name + ".json")).write_text(json.dumps(value, ensure_ascii=False))
cmd = [sys.executable, str(MODPATH), "verify-publication", "--before", str(root / "before.json"),
"--after", str(root / "after.json"), "--exact-review", str(root / "exact.json"),
"--result", str(root / "result.json"), "--allowed-paths", str(root / "paths.json"),
"--inline-comments", str(root / "inline.json"), "--reviewer-id", "42",
"--expected-pr-url", "https://example.test/acme/repo/pulls/7"]
p = subprocess.run(cmd, capture_output=True, text=True, env=env)
self.assertEqual(p.returncode, 0, p.stderr)
self.assertEqual(json.loads(p.stdout)["review_id"], 11)
body_path = root / "body.md"; body_path.write_bytes(review["body"].replace("\n", "\r\n").encode())
p = subprocess.run([sys.executable, str(MODPATH), "parse", "--body", str(body_path),
"--allowed-paths", str(root / "paths.json")], capture_output=True, text=True, env=env)
self.assertNotEqual(p.returncode, 0)
self.assertEqual(json.loads(p.stderr), {"error": "invalid input"})
def test_publication_accepts_causal_event_timestamp_without_forcing_clock_equality(self):
before, after, review, r, paths = publication_fixture()
event = after["timeline"][0]
event["created_at"] = event["updated_at"] = "2026-10-03T01:24:01Z"
after["pr"]["updated_at"] = event["created_at"]
verify_fixture(before, after, review, r, paths)
def test_publication_requires_independent_canonical_pr_url_anchor(self):
before, after, review, r, paths = publication_fixture()
for foreign in ("https://evil.test/acme/repo/pulls/7", "https://example.test/prefix/acme/repo/pulls/7"):
b = copy.deepcopy(before); a = copy.deepcopy(after)
b["pr"]["html_url"] = b["pr"]["url"] = foreign
a["pr"]["html_url"] = a["pr"]["url"] = foreign
a["reviews"][0]["pull_request_url"] = foreign
a["timeline"][0]["pull_request_url"] = foreign
with self.subTest(url=foreign), self.assertRaises(mod.ValidationError):
verify_fixture(b, a, a["reviews"][0], r, paths)
if __name__ == "__main__":
unittest.main()