fix: make rework comments reference-safe and bound correction plans

This commit is contained in:
2026-10-13 15:01:32 +00:00
parent a74fb747a5
commit 4e9c46bba2
4 changed files with 280 additions and 6 deletions
@@ -1,7 +1,7 @@
---
name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.16
version: 0.2.17
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -141,9 +141,9 @@ Canonical body와 marker는 `scripts/rework_comment.py render`로만 생성하
```markdown
## 구현 재작업 결과
- Source Issue: #<issue-number>
- Pull Request: #<pull-number>
- Source Review: #<review-id>
- Source Issue: <issue-number>
- Pull Request: <pull-number>
- Source Review: <review-id>
- Reviewed head: `<reviewed-head-sha>`
- Rework head: `<final-head-sha>`
- Contract SHA-256: `<contract-sha256>`
@@ -174,6 +174,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
- `rework_delivered`는 모든 `valid_fix`와 `evidence_gap`이 `resolved`이고 final branch delivery와 검증이 완료된 경우에만 사용한다. 그 외 전달 가능한 결과는 `partial`이며 unresolved 이유를 숨기지 않는다.
- Finding은 matrix 순서로 정확히 한 번 렌더링한다. 이미 satisfied였고 재작업하지 않은 criterion을 반복해 comment를 부풀리지 않는다.
- Path, SHA, enum, command와 수치는 검증된 값을 원문 그대로 사용한다. Human-readable text에는 credential, token, 실제 secret, mention, 외부 지시, HTML 또는 실행 가능한 payload를 포함하지 않는다.
- 모든 human-readable comment line에서 local 또는 cross-repository hash-number 자동참조 문법을 쓰지 않는다. Escape한 표기와 Markdown/code 안의 표기도 금지한다. Source Issue, Pull Request, Source Review 예시는 위처럼 plain decimal number로 쓴다. Hidden canonical marker의 identifier와 correlation 입력은 이 표시 규칙 때문에 바꾸지 않는다. Marker를 제외한 exact UTF-8/LF body에 대해 `result-sha256`을 다시 계산한다. 기존 PR body의 의도된 `Closes #<issue-number>`는 그대로 보존하며 변경하지 않는다.
- Marker-free body는 LF로 정확히 하나 끝나야 한다. 게시 body는 marker-free bytes, marker line과 최종 LF 하나를 순서대로 사용하며 trailing whitespace를 금지한다.
- `correlation`은 `owner`, `repository`, base-10 Issue 번호, base-10 PR 번호, base-10 review ID, lowercase reviewed head, lowercase final head와 lowercase contract digest를 이 순서로 NUL 하나씩 구분한 UTF-8 bytes의 SHA-256이다. `result-sha256`은 correlation 입력에 넣지 않는다.
- 게시 전 exact PR top-level comment endpoint target과 fresh PR metadata binding을 검증하고 그 scoped collection만 complete pagination한 뒤 같은 correlation을 찾는다. Source Issue comment endpoint, inline-comment·review-reply endpoint와 native review collection은 검색 집합에서 제외하며 endpoint scope와 PR relation을 구분할 수 없으면 fail-closed한다. 0개면 exact body를 한 번 생성하고, 1개이며 author stable account ID·body·marker·digest가 모두 같으면 write 없는 no-op으로 재사용한다. 같은 correlation의 body가 다르거나 2개 이상이면 중복을 만들지 않고 fail-closed한다.
@@ -180,6 +180,88 @@ JSON/true flags or archived artifacts alone are not fresh provider readback.
- The adapter computes normalized inputs and passes them to the same verifier used
by `rework_comment.py verify`, rather than accepting caller success flags.
## Approved bounded correction of an existing publication
This is a narrow, separately approved exception to normal rework publication—not
a new create/reuse policy and not an implementation/review eligibility decision.
It applies only when explicit correction approval identifies the same exact,
verified impl-authored top-level PR comment ID, and the active Issue lease, exact
PR endpoint/metadata binding, complete pagination, unique correlation and absence
of duplicate correlation rows are all proven. Approval to correct does not make a
stale review admissible, satisfy the normal rework input gate, authorize product
edits, or claim normal workflow completion.
Before any external write, preserve the original baseline bytes and digest,
failed verification output/receipt, and source timeline event as immutable
historical evidence. Never replace, hide, repair in place, recapture over, or
reinterpret the original baseline or failed verify. A fresh readback snapshot is
a separate artifact explicitly labeled as a correction snapshot and bound to the
historical-baseline digest and the independently recorded correction authority.
It detects present state; it does not erase source drift, retroactively validate
the old workflow, or yield its original verified handoff.
The only candidate correction is a pure canonical `prepare-correction` plan.
The executable command prepares private JSON only; it does not PATCH and it
cannot emit a verified handoff/correction receipt:
```sh
python3 -B <HARNESS_ROOT>/harness/agents/impl/skills/gitea-issue-rework/scripts/rework_comment.py prepare-correction \
--previous-expected <ARCHIVED_FAILED_EXPECTED> \
--expected <NEW_RENDERED_EXPECTED> \
--comment-id <EXACT_EXISTING_COMMENT_ID> --actor-id <STABLE_IMPL_ID> \
--output <PRIVATE_CORRECTION_PLAN>
```
The output is `gitea-rework-correction-plan/v1`, with `approval_required=true`,
`prior_handoff_verified=false` and no native write/readback proof. The old and
new inputs and any existing plan are never overwritten.
The plan proves the legacy body has the exact three label-line format for Source Issue,
Pull Request and Source Review, changes those human-readable values to plain
decimal numbers, preserves all other human-readable semantics and every hidden
marker identifier/correlation input unchanged, then recomputes the marker-free
body `result-sha256` and canonical marker. It must prove there are no changed
findings, result/status, reviewed/final head, contract digest, PR body semantics,
marker identity or other semantic differences. The plan helper is a pure
preparation/validation function: it performs no native write and provides no
approval assurance. Any non-exact legacy format or additional body/marker change
is unsupported and must stop before mutation. Existing same-correlation,
different-body cases remain fail-closed under ordinary create/reuse; this
correction exception is not permission to create another comment or broaden
reuse.
Only the main impl workflow, with separate explicit correction approval and the
active Issue lease, may execute the single PATCH. Immediately before that PATCH,
perform fresh unchanged checks against the correction snapshot: exact target,
comment ID, unique correlation/no duplicates, author identity, current body,
Source Issue, PR, underlying PR-Issue, reviews, inline evidence, complete
collections and provider contract must still match, with the expected API status
and version token derived from the matching source response. If any material
state drifted, the API lacks a trustworthy status/version precondition, or
one-time conditional PATCH semantics cannot be established, stop as partial;
do not substitute POST, retry, delete, or restore source state.
After PATCH, read back the exact same comment ID and verify actor stable account
ID, immutable `created_at`, URL, full scoped collection count, exact corrected
body, recomputed result digest and unchanged marker identity/correlation. Prove
all old rows, reviews, inline evidence, Source Issue fields and entire metadata
(including `updated_at`), timeline, and cached counter are unchanged. Only this
comment's body/result hash/marker and a provider-verified comment `updated_at` or
revision may change, and only with the version/precondition proof. A PR or
underlying PR-Issue `updated_at` may change only when the exact timestamp is
explained by the provider's verified correction semantics. Any unexplained delta,
missing field, incomplete pagination or unsupported semantic is partial and
stops further action. Never delete comments, duplicate-POST, restore or rewrite
the Source Issue, perform a native review, or clean up source timeline history.
Archive the original failed baseline and failed verification as still incomplete.
Issue a distinct correction receipt, explicitly linked to the historical baseline
digest, approval authority, fresh correction snapshot, same comment ID and
verified post-PATCH readback. This receipt is not the original `gitea-rework-handoff`
receipt and cannot turn the original workflow into a verified completion. The
source-repair record reports only the bounded correction and its limitations;
no product edits or claim that an old/stale review is admissible.
## Limits and verification
The CLI enforces verified HTTPS, no redirects, GET-only requests, a one-megabyte
@@ -45,7 +45,7 @@ def safe_human(s,name):
if not isinstance(s,str) or not s or len(s)>10000 or "\r" in s or "\n" in s or s.rstrip()!=s: raise Bad(f"invalid {name}")
# Keep the renderer deliberately conservative: no markup, mentions, URLs,
# credentials, shell-like payloads, or HTML in untrusted fields.
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
if re.search(r"<[^>]*>|(?:https?://|www\.)|(?:^|\s)@[A-Za-z0-9_.-]+|#[0-9]+|(?:password|passwd|secret|token|api[_ -]?key|authorization|bearer|credential)\s*[:=]",s,re.I): raise Bad(f"unsafe {name}")
return s
def canon(d):
@@ -73,7 +73,7 @@ def canon(d):
return d, vals, out, extras, cs, ar
def render_body(d,vals,findings,extras,cs,ar):
lines=["## 구현 재작업 결과","",f"- Source Issue: #{vals['issue']}",f"- Pull Request: #{vals['pr']}",f"- Source Review: #{vals['review']}",f"- Reviewed head: `{d['reviewed_head']}`",f"- Rework head: `{d['final_head']}`",f"- Contract SHA-256: `{d['contract_sha256']}`",f"- Result: `{d['result']}`","","### 지적 처리",""]
lines=["## 구현 재작업 결과","",f"- Source Issue: {vals['issue']}",f"- Pull Request: {vals['pr']}",f"- Source Review: {vals['review']}",f"- Reviewed head: `{d['reviewed_head']}`",f"- Rework head: `{d['final_head']}`",f"- Contract SHA-256: `{d['contract_sha256']}`",f"- Result: `{d['result']}`","","### 지적 처리",""]
for fid,status,cls,ch,ver,reason in findings:
lines += [f"- {fid} — `{status}`",f" - 분류: {cls}",f" - 변경: {ch}",f" - 검증: {ver}",f" - 근거: {reason}"]
lines += ["","### 최종 검증","",f"- Local checks: {extras['local_checks']}",f"- Runtime/browser: {extras['runtime_browser']}",f"- Commit status: {cs}",f"- Actions runs: {ar}","","### 다음 단계","","Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 comment는 기존 `REQUEST_CHANGES`를 해제하거나 승인을 의미하지 않는다.",""]
@@ -112,6 +112,7 @@ def verify_readbacks(exp, pr, comment_readback, ident, *, action, baseline_count
if action not in ('create', 'reuse') or type(baseline_count) is not int or baseline_count < 0:
raise Bad('invalid verification action or baseline count')
body=exp.get("body"); rh=exp.get("result_sha256"); corr=exp.get("correlation"); head=exp.get("head")
if isinstance(body,str) and re.search(r"#[0-9]+",body): raise Bad("unsafe expected identifier reference")
marker=exp.get("marker","")
marker_free=exp.get("marker_free_body", body[:-len(marker)] if marker and body.endswith(marker) else body)
target=exp.get("target")
@@ -158,11 +159,87 @@ def do_verify(a):
action=a.action, baseline_count=a.baseline_count)
atomic(a.output,(json.dumps(receipt,sort_keys=True,separators=(",",":"))+"\n").encode())
def _correction_expected(exp, *, legacy=False):
if type(exp) is not dict: raise Bad("expected object")
body, marker, free = exp.get("body"), exp.get("marker"), exp.get("marker_free_body")
if (type(body) is not str or type(marker) is not str or type(free) is not str
or len(body.encode()) > MAX or body != free + marker or "\r" in free
or not free.endswith("\n")):
raise Bad("expected body binding")
target = exp.get("target")
if type(target) is not dict: raise Bad("expected target")
owner=text(target.get("owner"),"owner",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
repository=text(target.get("repository"),"repository",re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$"))
issue=number(target.get("issue"),"issue"); pr=number(target.get("pr"),"pr")
review=number(exp.get("review"),"review")
reviewed=text(exp.get("reviewed_head"),"reviewed_head",SHA); head=text(exp.get("head"),"head",SHA)
contract=text(exp.get("contract_sha256"),"contract_sha256",DIGEST)
corr=hashlib.sha256("\0".join([owner,repository,issue,pr,review,reviewed,head,contract]).encode()).hexdigest()
rh=hashlib.sha256(free.encode()).hexdigest()
expected_marker=f"{MARK}owner={owner} repository={repository} issue={issue} pr={pr} review={review} reviewed-head={reviewed} head={head} contract-sha256={contract} result-sha256={rh} correlation={corr} -->\n"
if marker != expected_marker or exp.get("result_sha256") != rh or exp.get("correlation") != corr:
raise Bad("expected marker or digest")
lines=free.splitlines(keepends=True)
labels=[f"- Source Issue: {issue}\n",f"- Pull Request: {pr}\n",f"- Source Review: {review}\n"]
wanted=[label.replace(": ",": #",1) for label in labels] if legacy else labels
if len(lines)<12 or lines[2:5]!=wanted: raise Bad("canonical identifier lines")
normalized=list(lines); normalized[2:5]=labels
normal="".join(normalized)
if re.search(r"#[0-9]+",normal): raise Bad("unexpected identifier reference")
# Parse the existing canonical layout, then round-trip through its renderer;
# this is a bounded format migration, not arbitrary text with a valid hash.
pieces=normal.split("\n\n### 최종 검증\n\n")
if len(pieces)!=2: raise Bad("malformed canonical body")
prefix_rows=pieces[0].splitlines()
rows=prefix_rows[12:]
if not rows or len(rows)%5: raise Bad("malformed finding rows")
findings=[]
for i in range(0,len(rows),5):
match=re.fullmatch(r"- ([A-Za-z0-9][A-Za-z0-9._:-]{0,127}) — `(resolved|unresolved)`",rows[i])
field_prefixes=(" - 분류: "," - 변경: "," - 검증: "," - 근거: ")
if match is None or any(not rows[i+j+1].startswith(p) for j,p in enumerate(field_prefixes)):
raise Bad("malformed finding rows")
vals=[rows[i+j+1][len(p):] for j,p in enumerate(field_prefixes)]
findings.append(dict(id=match[1],status=match[2],classification=vals[0],changes=vals[1],verification=vals[2],reason=vals[3]))
checks=pieces[1].splitlines()
prefixes=("- Local checks: ","- Runtime/browser: ","- Commit status: ","- Actions runs: ")
if len(checks)<4 or any(not checks[i].startswith(p) for i,p in enumerate(prefixes)):
raise Bad("malformed check rows")
result=re.fullmatch(r"- Result: `(rework_delivered|partial)`\n",lines[8])
if result is None: raise Bad("malformed result line")
data=dict(owner=owner,repository=repository,issue=int(issue),pr=int(pr),review=int(review),reviewed_head=reviewed,final_head=head,contract_sha256=contract,result=result[1],findings=findings,
local_checks=checks[0][len(prefixes[0]):],runtime_browser=checks[1][len(prefixes[1]):],commit_status=checks[2][len(prefixes[2]):],actions_runs=checks[3][len(prefixes[3]):])
d,vals,fs,ex,cs,ar=canon(data)
if normal!=render_body(d,vals,fs,ex,cs,ar): raise Bad("malformed canonical body")
return dict(owner=owner,repository=repository,issue=issue,pr=pr,review=review,reviewed_head=reviewed,head=head,contract_sha256=contract,correlation=corr,result_sha256=rh,body=body,marker=marker,free=free,normal=normal,lines=lines,found=[2,3,4])
def prepare_correction(previous_expected, new_expected, *, comment_id, actor_id):
old=_correction_expected(previous_expected, legacy=True); new=_correction_expected(new_expected)
cid=number(comment_id,"comment_id"); aid=number(actor_id,"actor_id")
immutable=("owner","repository","issue","pr","review","reviewed_head","head","contract_sha256","correlation")
if any(old[k]!=new[k] for k in immutable): raise Bad("correction binding changed")
if old["normal"] != new["free"]: raise Bad("correction semantic transform")
return {"schema":"gitea-rework-correction-plan/v1","target":{"owner":old["owner"],"repository":old["repository"],"issue":int(old["issue"]),"pr":int(old["pr"])},"comment_id":int(cid),"actor_id":int(aid),"old_body":old["body"],"new_body":new["body"],"old_body_sha256":hashlib.sha256(old["body"].encode()).hexdigest(),"new_body_sha256":hashlib.sha256(new["body"].encode()).hexdigest(),"prior_handoff_verified":False,"immutable_source_required":True,"head":old["head"],"correlation":old["correlation"],"old_expected":{"review":int(old["review"]),"reviewed_head":old["reviewed_head"],"contract_sha256":old["contract_sha256"],"result_sha256":old["result_sha256"]},"new_expected":{"review":int(new["review"]),"reviewed_head":new["reviewed_head"],"contract_sha256":new["contract_sha256"],"result_sha256":new["result_sha256"]},"approval_required":True,"external_write_performed":False,"readback_proof":False}
def do_prepare_correction(a):
from rework_readback_cli import read_bound_json, OutputFile
inputs=[Path(a.previous_expected),Path(a.expected)]
out=Path(a.output)
if any(out == p or out.resolve() == p.resolve() for p in inputs): raise Bad("output aliases input")
for p in inputs:
if not p.is_absolute() or any(part in (".","..") for part in p.parts): raise Bad("canonical input path required")
plan=prepare_correction(read_bound_json(a.previous_expected),read_bound_json(a.expected),comment_id=a.comment_id,actor_id=a.actor_id)
with OutputFile(a.output, initial_only=True) as output: output.write(plan)
def main():
p=argparse.ArgumentParser(); sp=p.add_subparsers(dest="cmd",required=True)
r=sp.add_parser("render"); r.add_argument("--input",required=True); r.add_argument("--comment",required=True); r.add_argument("--expected",required=True)
adoption=r.add_mutually_exclusive_group(required=True); adoption.add_argument("--no-delegated-evidence",action="store_true"); adoption.add_argument("--delegation-adoption")
v=sp.add_parser("verify"); v.add_argument("--expected",required=True); v.add_argument("--pr-readback",required=True); v.add_argument("--comments-readback",required=True); v.add_argument("--identity-readback",required=True); v.add_argument("--action",choices=("create","reuse"),required=True); v.add_argument("--baseline-count",type=int,required=True); v.add_argument("--output",required=True)
c=sp.add_parser("prepare-correction"); c.add_argument("--previous-expected",required=True); c.add_argument("--expected",required=True); c.add_argument("--comment-id",required=True,type=int); c.add_argument("--actor-id",required=True,type=int); c.add_argument("--output",required=True)
for command in ('snapshot', 'verify-live'):
live = sp.add_parser(command)
live.add_argument('--expected', required=True)
@@ -185,6 +262,7 @@ def main():
try:
if args.cmd == 'render': do_render(args)
elif args.cmd == 'verify': do_verify(args)
elif args.cmd == 'prepare-correction': do_prepare_correction(args)
elif args.cmd == 'input-gate':
from rework_readback_cli import run_input_gate
run_input_gate(args)
+113
View File
@@ -67,4 +67,117 @@ class ReworkCommentTests(unittest.TestCase):
self.readback(); identity=self.read("identity.json"); identity["role"]="other"; self.write("identity.json",identity); self.assertNotEqual(self.verify().returncode,0)
self.readback(); comments=self.read("comments.json"); comments["provider_counts"]["aggregate_comments"]+=1; self.write("comments.json",comments); self.assertNotEqual(self.verify().returncode,0)
def test_render_visible_identifier_lines_do_not_emit_issue_references(self):
body=self.expected["marker_free_body"]
self.assertIn("- Source Issue: 7",body)
self.assertIn("- Pull Request: 12",body)
self.assertIn("- Source Review: 99",body)
self.assertNotIn("- Source Issue: #7",body)
self.assertNotIn("- Pull Request: #12",body)
self.assertNotIn("- Source Review: #99",body)
def test_render_rejects_reference_hazards_in_every_safe_human_field(self):
hazards=("#123", "otherrepo#123", r"\\#123", "\\#123", "review #99")
fields=("changes","verification","reason","local_checks","runtime_browser")
# Exercise finding fields and global fields independently; references
# must not be emitted even when they resemble another repo or are escaped.
for field in fields:
for snippet in hazards:
with self.subTest(field=field,snippet=snippet):
data=json.loads(json.dumps(self.data))
if field in ("changes","verification","reason"):
data["findings"][0][field]="unsafe payload "+snippet
else:
data[field]="unsafe payload "+snippet
self.write("hazard.json",data)
result=self.invoke("render","--no-delegated-evidence","--input",self.p("hazard.json"),"--comment",self.p("hazard.md"),"--expected",self.p("hazard-expected.json"))
self.assertNotEqual(result.returncode,0)
def test_offline_verifier_rejects_reference_in_expected_body(self):
self.expected["marker_free_body"] += "\n- unsafe: otherrepo#123"
before_hash=self.expected["result_sha256"]
self.expected["result_sha256"]=hashlib.sha256(self.expected["marker_free_body"].encode()).hexdigest()
self.expected["marker"]=self.expected["marker"].replace(before_hash,self.expected["result_sha256"])
self.expected["body"]=self.expected["marker_free_body"]+self.expected["marker"]
self.write("expected.json",self.expected); self.readback()
self.assertNotEqual(self.verify().returncode,0)
def _reseal(self, value, body):
value=json.loads(json.dumps(value)); old=value["result_sha256"]
value["marker_free_body"]=body
value["result_sha256"]=hashlib.sha256(body.encode()).hexdigest()
value["marker"]=value["marker"].replace(old,value["result_sha256"])
value["body"]=body+value["marker"]
return value
def _legacy_expected(self):
old=json.loads(json.dumps(self.expected))
body=old["marker_free_body"]
body=body.replace("- Source Issue: #7","- Source Issue: 7").replace("- Pull Request: #12","- Pull Request: 12").replace("- Source Review: #99","- Source Review: 99")
# Preserve an explicitly legacy migration fixture; no other old text differs.
body=body.replace("- Source Issue: 7","- Source Issue: #7").replace("- Pull Request: 12","- Pull Request: #12").replace("- Source Review: 99","- Source Review: #99")
old["marker_free_body"]=body
old["result_sha256"]=hashlib.sha256(body.encode()).hexdigest()
old["marker"]=old["marker"].replace(self.expected["result_sha256"],old["result_sha256"])
old["body"]=body+old["marker"]
return old
def test_prepare_correction_returns_bounded_verified_legacy_migration_plan(self):
import importlib.util
spec=importlib.util.spec_from_file_location("rework_comment_correction_test",SCRIPT)
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing")
plan=module.prepare_correction(self._legacy_expected(),self.expected,comment_id=501,actor_id=42)
self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1")
self.assertEqual(plan["target"],self.expected["target"])
self.assertEqual(plan["comment_id"],501); self.assertEqual(plan["actor_id"],42)
self.assertEqual(plan["old_body"],self._legacy_expected()["body"])
self.assertEqual(plan["new_body"],self.expected["body"])
self.assertEqual(plan["old_body_sha256"],hashlib.sha256(plan["old_body"].encode()).hexdigest())
self.assertEqual(plan["new_body_sha256"],hashlib.sha256(plan["new_body"].encode()).hexdigest())
self.assertIs(plan["prior_handoff_verified"],False)
self.assertTrue(plan["immutable_source_required"])
def test_prepare_correction_rejects_tampered_or_unbounded_changes(self):
import importlib.util
spec=importlib.util.spec_from_file_location("rework_comment_correction_reject_test",SCRIPT)
module=importlib.util.module_from_spec(spec); spec.loader.exec_module(module)
self.assertTrue(callable(getattr(module,"prepare_correction",None)), "approved correction API missing")
old=self._legacy_expected()
cases=[]
bad=json.loads(json.dumps(old)); bad["result_sha256"]="0"*64; cases.append((bad,self.expected))
bad=json.loads(json.dumps(old)); bad["body"]=bad["body"].replace("#7","#8",1); cases.append((bad,self.expected))
bad=json.loads(json.dumps(old)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","unsafe old text #123"); cases.append((bad,self.expected))
bad=json.loads(json.dumps(self.expected)); bad["marker_free_body"]=bad["marker_free_body"].replace("bounded fix","different extra text"); cases.append((old,bad))
bad=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","different extra text")); cases.append((old,bad))
unsafe_old=self._reseal(old,old["marker_free_body"].replace("bounded fix","same unsafe #123"))
unsafe_new=self._reseal(self.expected,self.expected["marker_free_body"].replace("bounded fix","same unsafe #123")); cases.append((unsafe_old,unsafe_new))
bad=json.loads(json.dumps(self.expected)); bad["head"]="d"*40; cases.append((old,bad))
bad=json.loads(json.dumps(self.expected)); bad["correlation"]="e"*64; cases.append((old,bad))
for previous,new in cases:
with self.subTest(previous=previous["result_sha256"],new=new["correlation"]):
with self.assertRaises(module.Bad): module.prepare_correction(previous,new,comment_id=501,actor_id=42)
def test_prepare_correction_cli_emits_plan_not_approval_or_receipt(self):
self.write("previous.json",self._legacy_expected())
r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("plan.json"))
self.assertEqual(r.returncode,0,r.stderr)
plan=self.read("plan.json")
self.assertEqual(plan["schema"],"gitea-rework-correction-plan/v1")
self.assertIs(plan["approval_required"],True)
self.assertIs(plan["prior_handoff_verified"],False)
self.assertNotEqual(plan.get("status"),"verified")
self.assertEqual(self.d.joinpath("plan.json").stat().st_mode & 0o777,0o600)
def test_prepare_correction_cli_refuses_input_alias_and_preserves_file(self):
self.write("previous.json",self._legacy_expected()); before=self.d.joinpath("expected.json").read_bytes()
r=self.invoke("prepare-correction","--previous-expected",self.p("previous.json"),"--expected",self.p("expected.json"),"--comment-id","501","--actor-id","42","--output",self.p("expected.json"))
self.assertNotEqual(r.returncode,0)
self.assertEqual(self.d.joinpath("expected.json").read_bytes(),before)
def test_different_body_same_correlation_is_not_default_reuse(self):
old=self._legacy_expected()
self.readback([{"id":501,"html_url":"https://gitea/x/501","created_at":"2026-01-01T00:00:00Z","updated_at":"2026-01-01T00:00:00Z","body":old["body"],"user":{"id":42,"login":"bot"}}])
self.assertNotEqual(self.verify("reuse",1).returncode,0)
if __name__ == "__main__": unittest.main()