fix: make rework comments reference-safe and bound correction plans
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user