fix: validate Gitea conversation caches and safe commit messages

This commit is contained in:
2026-10-13 13:50:10 +00:00
parent a7a13aa4d5
commit a74fb747a5
6 changed files with 227 additions and 17 deletions
@@ -1,7 +1,7 @@
---
name: gitea-issue-rework
description: Gitea PR 검토 지적을 계약에 맞춰 재작업하고 검증한다.
version: 0.2.15
version: 0.2.16
author: genichin, Hermes Agent
license: MIT
platforms: [linux]
@@ -178,7 +178,7 @@ Reviewer가 rework head를 새 snapshot으로 다시 검토해야 하며 이 com
- `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한다.
- Comment create timeout·오류 뒤에는 blind retry하지 않는다. Complete comment collection을 한 번 authoritative하게 reconcile해 exact result 하나가 확인될 때만 성공으로 판정한다.
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 fresh top-level·review·inline comment cardinality로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
- 게시 또는 재사용 후 exact comment ID, author stable account ID, body, created·updated timestamp, marker, digest, verified endpoint target, fresh PR metadata binding과 PR top-level collection cardinality를 readback한다. 신규 게시에서는 scoped PR top-level comment collection에 exact comment 하나만 추가되어야 한다. Provider의 aggregate comment-count field는 명시된 version-specific 계약으로 설명 가능해야 하며 설명할 수 없으면 fail-closed한다. `gitea-conversation-v1` cache opt-in은 [cached conversation admission](references/readback.md#cached-conversation-admission)에 따라 snapshot의 bounded stale lag만 허용하고, 신규 게시 후에는 실제 전체 conversation 개수와 exact-match를 요구한다. Cached baseline에 무조건 `+1`을 적용하지 않으며 reuse의 counter·metadata 불변 조건은 유지한다. 신규 게시에서 PR·underlying PR-Issue의 `updated_at`은 불변이거나 exact 신규 comment 생성 timestamp와 같을 때만 허용한다. 이는 다른 native metadata가 불변인 상태의 state-delta/exact-timestamp admission이며, GET readback만으로 독립적인 causal event attestation을 제공하지 않는다. Source Issue `<issue-number>`는 `updated_at`을 포함한 전체 native metadata와 comment collection이 완전히 불변이어야 하며, provider relation을 이유로 timestamp 예외를 허용하지 않는다. Existing exact comment 재사용에서는 PR top-level collection cardinality, PR·underlying issue와 Source Issue field가 모두 불변이어야 한다. 기존 review collection 또는 그 밖의 PR field 변경은 허용하지 않는다.
완료 조건: exact final head와 finding matrix에 결합된 canonical comment가 impl role author로 정확히 하나 존재하고 authoritative readback을 통과한다.
@@ -277,7 +277,7 @@ pytest를 이미 사용하는 프로젝트의 evidence command에만 적용한
3. Worktree의 staged·unstaged·untracked state와 미완료 Git operation을 확인한다.
4. Contract Preservation Inventory가 필요한 경우 final tree에 다시 대응해 baseline 항목 수와 실제 상태를 검증하고 `missing=0`, `ambiguous=0`이 아니면 stage하거나 commit하지 않는다.
5. Exact allowlist만 stage하고 staged diff를 다시 읽는다.
6. Repository convention에 맞는 새 non-amended rework commit을 생성한다. Review별 traceability가 필요하면 commit message나 검증 기록에 PR과 review ID를 포함하되 review body를 그대로 복제하지 않는다.
6. Commit 전에 subject와 body를 공통 [commit-message reference safety](../../../../operations/repository-cache.md#commit-message-reference-safety) 규칙에 따라 확인한다. Protected Issue metadata가 immutable이어야 하면 hash-number auto-reference와 parser-triggering closing/reopening 문구를 피하고 `Issue 18 / PR 19 / review 76` 또는 evidence로 추적한다. 기본은 새 non-amended commit이며, amend는 명시적 사용자 승인·미게시 증명·tree 동일성 증명과 새 SHA exact verification이 모두 있을 때만 허용된다. 일반 amend 권한으로 해석하지 않는다.
7. Commit 후 clean HEAD에서 최종 formatter, test와 필요한 runtime·browser 검증을 다시 실행한다. 이 뒤 repository file을 바꾸면 영향을 받은 검증을 다시 실행한다.
8. 최종 검증 기록은 exact HEAD 또는 검증한 source tree, command, exit code, pass·skip·fail 수치, 환경, relevant output과 reviewer가 접근 가능한 evidence location을 포함하며, PR body·결과 comment의 각 실행 주장과 수치는 해당 location에 일대일 대응시킨다. Hermes 내부 tool transcript, delegate 자기보고, 삭제 예정 임시 파일, reviewer가 접근할 수 없는 local path와 요약 수치만 있는 기록은 evidence로 인정하지 않고 `evidence_gap` 또는 `partial`로 유지한다. 새 evidence는 checkout 밖 정본 verification root에 보존하여 final HEAD를 기록하며 evidence-only descendant를 만들지 않는다. 과거 tracked evidence의 archived 분석은 fresh 실행·원격 head 검증과 구분한다.
@@ -34,7 +34,7 @@ Initial intake validates collected rows, endpoint/pagination provenance, duplica
The receipt adds `counter_diagnostics`, with keys `inline`, `pr` and `pr_issue`. Each entry records the declared `field`/`components`, unmodified `observed` value, collection-derived `expected` value, and `status`: `match`, `mismatch`, or `unverified` (missing, negative or non-integer observed counter, including booleans). A match means only that the explicitly declared formula matches this observation; it does not attest deployed-provider semantics or counter freshness. Diagnostics never auto-select a projection, modify raw counts, or grant edit/publication permission. Missing/malformed PR counters do not bypass malformed native records, failed GETs, incomplete lists or drift checks.
Provider schema/version binding and explicit declarations are still required. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
Initial diagnostic intake remains unchanged. Publication requires strict counter validation and explicit version-specific cache opt-in only: optional aggregate semantics are supported for `cache_mode: "gitea-conversation-v1"`, version `1.27.3`, with `comments` components exactly `["top_level", "reviews"]`; omission of the mode retains strict legacy behavior. No numeric fitting or automatic profile/provider adjustment. Rework/no-op/blocked decisions retain their role/head/contract/state rules regardless of diagnostics. `snapshot`/`verify-live` publication readback continues to require strict provider counter crosschecks and its existing explicitly declared fallback; an initial receipt cannot substitute for that verification.
## Derived decisions
@@ -36,28 +36,52 @@ pre-write delegation admission.
Schema: `gitea-agent.rework-provider/v1`. The contract explicitly declares the
aggregate semantics for PR and underlying PR-Issue `comments` fields. Each
`components` array contains unique values from `top_level`, `reviews`, `inline`
and must include `top_level`. Choose the reviewed deployment semantics, not the
and must include `top_level`. An optional `cache_mode: "gitea-conversation-v1"`
on each aggregate scope is accepted only for provider version `1.27.3`, field
`comments`, and components consisting of exactly `top_level` and `reviews`.
Omission retains strict legacy behavior. Choose the reviewed deployment semantics, not the
combination that happens to fit a current response. There is no guessed version
or automatic capability downgrade.
Shape example only, **not a claim about this deployment**:
Shape example only: upstream v1.27.3 source supports this shape; it does not attest that a custom build matches upstream:
```json
{
"schema": "gitea-agent.rework-provider/v1",
"version": "<independently-observed-version>",
"version": "1.27.3",
"aggregate": {
"pr": {"field": "comments", "components": ["top_level"]},
"pr_issue": {"field": "comments", "components": ["top_level"]}
"pr": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"},
"pr_issue": {"field": "comments", "components": ["top_level", "reviews"], "cache_mode": "gitea-conversation-v1"}
},
"inline": {
"mode": "per_review",
"field": "review_comments",
"components": ["inline"]
"components": ["reviews"]
}
}
```
### Cached conversation admission
For the explicit cache mode, snapshot counters must be present non-boolean,
nonnegative integers no greater than the complete collection-derived conversation
count. This admits bounded stale lag, not arbitrary mismatch; counters ahead of
the collected count, missing values and malformed scalars remain BLOCK.
Upstream `v1.27.3` `services/convert/issue.go` reads stored `Issue.NumComments`.
`models/issues/comment.go` counts plain comments and review-summary comments;
review creation bypasses the recomputation that plain-comment creation performs.
This source pin explains the opt-in policy, not a deployed custom-build attestation.
For `create`, fresh readback must prove exactly one canonical top-level addition
and the recomputed aggregate must equal the complete post-write conversation
count. A stale baseline counter `0` with one review can become `2` after the new
plain comment; `before counter + 1` is not the cache-mode rule. All old rows,
reviews, inline evidence, Source Issue metadata/comments, identity, body, head
and unrelated native fields retain their existing protections. For `reuse`, no
addition or counter/metadata refresh is admitted: even a legitimate-looking
cache change without the authorized create action is BLOCK. Strict contracts
without cache opt-in retain exact snapshot projections and the original delta.
For `inline.components`, the explicit projection modes are `["inline"]`,
`["reviews"]`, and `["reviews", "inline"]`. The review-only mode projects
only the number of reviews into `review_comments`; it does not add per-review
@@ -138,11 +138,16 @@ class ReworkReadbackAdapter:
for scope in ('pr','pr_issue'):
spec = object_value(aggregate[scope])
parts = spec.get('components')
if (set(spec) != {'field','components'} or spec.get('field') != 'comments'
mode = spec.get('cache_mode')
allowed_keys = {'field','components'} if mode is None and 'cache_mode' not in spec else {'field','components','cache_mode'}
if (set(spec) != allowed_keys or spec.get('field') != 'comments'
or type(parts) is not list or not parts
or any(type(part) is not str or part not in ('top_level','reviews','inline') for part in parts)
or len(parts) != len(set(parts)) or 'top_level' not in parts):
raise ReadbackError('invalid aggregate count declaration')
if 'cache_mode' in spec and (mode != 'gitea-conversation-v1' or contract['version'] != '1.27.3'
or set(parts) != {'top_level','reviews'}):
raise ReadbackError('invalid aggregate cache declaration')
inline = object_value(contract.get('inline'))
if (set(inline) != {'mode','field','components'}
or inline.get('mode') not in ('per_review','per_review_or_pr_count')
@@ -392,7 +397,12 @@ class ReworkReadbackAdapter:
raise ReadbackError('inline provider counter crosscheck failed')
for scope in ('pr','pr_issue'):
spec = self.provider['aggregate'][scope]
if integer(raw[scope].get(spec['field'])) != sum(counts[name] for name in spec['components']):
observed = raw[scope].get(spec['field'])
computed = sum(counts[name] for name in spec['components'])
if spec.get('cache_mode') == 'gitea-conversation-v1':
if integer(observed) > computed:
raise ReadbackError('cached provider aggregate count exceeds collection')
elif integer(observed) != computed:
raise ReadbackError('provider aggregate count mismatch')
return counts
@@ -586,8 +596,17 @@ class ReworkReadbackAdapter:
raise ReadbackError('source Issue metadata drift')
else:
field = self.provider['aggregate'][name]['field']
if integer(now.pop(field)) != integer(before.pop(field)) + (1 if action == 'create' else 0):
spec = self.provider['aggregate'][name]
before_value, now_value = integer(before.get(field)), integer(now.get(field))
if spec.get('cache_mode') == 'gitea-conversation-v1':
desired = (sum(self.counts(a)[part] for part in spec['components'])
if action == 'create' else before_value)
if now_value != desired:
raise ReadbackError('cached PR counter causality mismatch')
elif now_value != before_value + (1 if action == 'create' else 0):
raise ReadbackError('PR counter delta causality mismatch')
before.pop(field)
now.pop(field)
if name != 'source_issue' and action == 'create' and now['updated_at'] != before['updated_at']:
if now['updated_at'] != matched['created_at']:
raise ReadbackError('uncausal Issue or PR timestamp')
+6
View File
@@ -163,6 +163,12 @@ python3 <harness-root>/harness/lib/managed_lock_broker.py probe \
7. 일치한 exact SHA를 이번 작업의 pinned source revision으로 기록한다.
8. Issue workflow는 같은 handle에 `release_repository`를 request한다. 별도 non-Issue workflow는 자신의 repository lock을 명시된 순서로 해제한다.
## Commit-message reference safety
Before every commit, inspect the complete subject and body for Gitea-parsed hash-number references to local or cross-repository Issues/PRs and closing/reopening keywords. When protected Issue metadata must remain immutable, avoid parser-triggering references: trace as `Issue 18 / PR 19 / review 76` (or cite evidence) rather than `#18`. A PR body’s canonical `Closes #…` reference remains canonical; this is not a blanket instruction to remove it. Using a non-default branch does not prevent reference events.
A message-only amend is allowed only with explicit user authorization, proof the commit is unpublished, and proof the tree is identical; then verify the new SHA exactly. This is not general permission to amend.
## Issue worktree 모델
- 한 repository의 한 Issue에는 persistent linked worktree 하나만 허용한다. worktree는 구현 결과가 안전하게 전달되거나 명시적으로 폐기될 때까지 세션 간 보존한다.
+165 -4
View File
@@ -185,7 +185,6 @@ class ReworkReadbackTests(unittest.TestCase):
('pr',['state'],'closed'), ('underlying',['number'],10),
('underlying',['pull_request'],True), ('reviews',[0,'commit_id'],'c'*40)]
error = self.load('rework_readback').ReadbackError
# Each load has a distinct exception class; assert ValueError at the public boundary.
self.assertTrue(issubclass(error, ValueError))
for attribute, keys, value in cases:
with self.subTest(attribute=attribute, keys=keys):
@@ -213,7 +212,7 @@ class ReworkReadbackTests(unittest.TestCase):
(first,self.comments[:50],{},200),
(first,[{'id':True}],{'X-Total-Count':'1'},200),
(first,[],{'X-Total-Count':'0','Link':'not a Link'},200),
(first,[],{'X-Total-Count':'0'},True), (first,[],{},403), (first,[],{},302)]
(first,[],{},True), (first,[],{},403), (first,[],{},302)]
for path, rows, headers, status in invalid:
with self.subTest(path=path, headers=headers, status=status):
self.overrides = {path:(rows,headers,status)}
@@ -299,7 +298,6 @@ class ReworkReadbackTests(unittest.TestCase):
baseline = adapter.snapshot(self.expected)
self.add_result()
after = adapter.snapshot(self.expected)
# Prove this exact clean fixture is admitted before adversarial mutation.
adapter.normalized_inputs(self.expected, baseline, after, 'create')
cases = [(['schema'],'forged'), (['actor_id'],True), (['origin'],'https://foreign.invalid/api/v1'),
(['target','owner'],'other'), (['provider','version'],'different'),
@@ -458,7 +456,6 @@ class ReworkReadbackTests(unittest.TestCase):
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_native_Gitea_PR_URL_and_omitted_proven_zero_inline_count(self):
# Pinned primary serializers: services/convert/pull.go and modules/structs/pull.go (v1.24.6).
self.pr['url'] = self.pr['html_url']
self.contract['inline']['components'] = ['inline']
self.pr.pop('review_comments')
@@ -543,3 +540,167 @@ class ReworkReadbackTests(unittest.TestCase):
reused = adapter.normalized_inputs(self.expected, after, adapter.snapshot(self.expected), 'reuse')
self.assertEqual(self.legacy.verify_readbacks(self.expected, reused['pr'], reused['comments'],
reused['identity'], action='reuse', baseline_count=reused['baseline_count'])['status'], 'verified')
def _cache_adapter(self):
self.contract['version'] = '1.27.3'
self.overrides['/version'] = ({'version':'1.27.3'}, {}, 200)
self.contract['aggregate']['pr'] = {'field':'comments','components':['top_level','reviews'],
'cache_mode':'gitea-conversation-v1'}
self.contract['aggregate']['pr_issue'] = {'field':'comments','components':['top_level','reviews'],
'cache_mode':'gitea-conversation-v1'}
return self.adapter()
def _stale_baseline(self):
self.pr['comments'] = self.underlying['comments'] = 0
adapter = self._cache_adapter()
return adapter, adapter.snapshot(self.expected)
def test_cache_opt_in_captures_and_validates_bounded_stale_baseline(self):
adapter, baseline = self._stale_baseline()
self.assertEqual(baseline['normalized_counts']['top_level'], 0)
self.assertEqual(baseline['normalized_counts']['reviews'], 1)
self.assertEqual(baseline['raw']['pr']['comments'], 0)
self.assertEqual(baseline['raw']['pr_issue']['comments'], 0)
adapter.validate_snapshot(self.expected, baseline)
self.overrides['/version'] = ({'version':'1.24.6'}, {}, 200)
with self.assertRaisesRegex(ValueError, 'provider version mismatch'):
adapter.snapshot(self.expected)
def test_cache_create_recomputes_post_conversation_count_and_guards_deltas(self):
adapter, baseline = self._stale_baseline()
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 2
after = adapter.snapshot(self.expected)
inputs = adapter.normalized_inputs(self.expected, baseline, after, 'create')
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
inputs['identity'], action='create', baseline_count=inputs['baseline_count'])
self.assertEqual(receipt['status'], 'verified')
# Capture a genuine pre-create synchronized baseline, not the post-create rows.
self.comments.clear()
self.pr['comments'] = self.underlying['comments'] = 1
self.pr['updated_at'] = self.underlying['updated_at'] = T0
synchronized = adapter.snapshot(self.expected)
self.add_result()
synchronized_after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, synchronized, synchronized_after, 'create')
for bad in (1, None, -1, 3):
changed = copy.deepcopy(after)
changed['raw']['pr']['comments'] = bad
changed['raw']['pr_issue']['comments'] = bad
with self.subTest(after_counter=bad), self.assertRaises(ValueError):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
for mutation in ('old_comment','duplicate','native_review','source_issue'):
changed = copy.deepcopy(after)
if mutation == 'old_comment':
changed['raw']['comments']['rows'] = [{'id':400,'body':'mutated','created_at':T0,
'updated_at':T0,'user':{'id':8,'login':'other'}}] + changed['raw']['comments']['rows']
elif mutation == 'duplicate':
changed['raw']['comments']['rows'].append(copy.deepcopy(changed['raw']['comments']['rows'][0]))
elif mutation == 'native_review': changed['raw']['reviews']['rows'][0]['state'] = 'APPROVED'
else: changed['raw']['source_issue']['title'] = 'drift'
with self.subTest(mutation=mutation), self.assertRaises(ValueError):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
def test_cache_reuse_allows_same_lag_but_rejects_refresh(self):
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 0
adapter = self._cache_adapter()
baseline = adapter.snapshot(self.expected)
same = adapter.snapshot(self.expected)
inputs = adapter.normalized_inputs(self.expected, baseline, same, 'reuse')
receipt = self.legacy.verify_readbacks(self.expected, inputs['pr'], inputs['comments'],
inputs['identity'], action='reuse', baseline_count=inputs['baseline_count'])
self.assertEqual(receipt['status'], 'verified')
self.pr['comments'] = self.underlying['comments'] = 2
refreshed = adapter.snapshot(self.expected)
with self.assertRaises(ValueError): adapter.normalized_inputs(self.expected, baseline, refreshed, 'reuse')
def test_cache_opt_in_rejects_invalid_mode_version_and_components(self):
module = self.load('rework_readback')
for mode, version, components in (
('unknown','1.27.3',['top_level','reviews']),
(None,'1.27.3',['top_level','reviews']),
(True,'1.27.3',['top_level','reviews']),
('gitea-conversation-v1','1.27.2',['top_level','reviews']),
('gitea-conversation-v1','1.27.3',['reviews']),
('gitea-conversation-v1','1.27.3',['top_level']),
('gitea-conversation-v1','1.27.3',['top_level','reviews','inline'])):
contract = copy.deepcopy(self.contract)
contract['version'] = version
contract['aggregate']['pr'] = {'field':'comments','components':components,'cache_mode':mode}
contract['aggregate']['pr_issue'] = {'field':'comments','components':components,'cache_mode':mode}
with self.subTest(mode=mode,version=version,components=components), self.assertRaises(ValueError):
module.ReworkReadbackAdapter(self.request, origin=ORIGIN+'/api/v1',
credential_source='$HERMES_HOME/.env:GITEA_TOKEN', actor_id=42, provider_contract=contract)
def test_strict_legacy_counter_semantics_are_unchanged_without_cache_mode(self):
self.pr['comments'] = self.underlying['comments'] = 0
with self.assertRaises(ValueError): self.adapter().snapshot(self.expected)
def test_cache_baseline_does_not_count_inline_records_as_conversation(self):
adapter = self._cache_adapter()
self.contract['inline']['components'] = ['reviews']
adapter = self.adapter()
self.reviews[0]['comments_count'] = 1
self.inline = [{'id':801,'body':'inline','created_at':T0,'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}]
self.pr['comments'] = self.underlying['comments'] = 2
with self.assertRaisesRegex(ValueError, 'cached provider aggregate count exceeds collection'):
adapter.snapshot(self.expected)
def test_cache_baseline_rejects_malformed_missing_and_ahead_counters(self):
adapter = self._cache_adapter()
for scope in (self.pr, self.underlying):
for bad in (None, -1, True, '0', 1.0, 2):
scope['comments'] = bad
with self.subTest(counter=bad), self.assertRaises(ValueError):
adapter.snapshot(self.expected)
scope.pop('comments')
with self.assertRaises(ValueError): adapter.snapshot(self.expected)
scope['comments'] = 1
def test_cache_transition_rejects_mutation_of_a_genuine_existing_comment(self):
old = {'id':400, 'html_url':self.pr['html_url']+'#issuecomment-400',
'body':'preserved previous comment', 'created_at':T0, 'updated_at':T0,
'user':{'id':33,'login':'gitea-review-agent'}}
self.comments.append(old)
adapter, baseline = self._stale_baseline()
self.add_result()
self.pr['comments'] = self.underlying['comments'] = 3
after = adapter.snapshot(self.expected)
adapter.normalized_inputs(self.expected, baseline, after, 'create')
changed = copy.deepcopy(after)
changed['raw']['comments']['rows'][0]['body'] = 'unauthorized mutation'
with self.assertRaisesRegex(ValueError, 'existing comment changed or missing'):
adapter.normalized_inputs(self.expected, baseline, changed, 'create')
def test_cache_opt_in_does_not_relax_an_undeclared_aggregate_scope(self):
self._cache_adapter()
self.contract['aggregate']['pr_issue'].pop('cache_mode')
adapter = self.adapter()
self.pr['comments'] = self.underlying['comments'] = 0
with self.assertRaisesRegex(ValueError, 'provider aggregate count mismatch'):
adapter.snapshot(self.expected)
self.underlying['comments'] = 1
adapter.snapshot(self.expected)
def test_cache_provider_documentation_example_matches_executable_contract(self):
import re
doc = (DIRECTORY.parent/'references/readback.md').read_text()
match = re.search(r'```json\s*(.*?)\s*```', doc, re.S)
if match is None: self.fail('provider documentation requires a JSON example')
example = json.loads(match.group(1))
self._cache_adapter()
self.contract = example
self.adapter().snapshot(self.expected)
def test_shared_commit_message_safety_rule_is_linked_and_amend_is_narrow(self):
common = (DIRECTORY.parents[4]/'operations/repository-cache.md').read_text()
skill = (DIRECTORY.parent/'SKILL.md').read_text()
self.assertIn('## Commit-message reference safety', common)
for clause in ('complete subject and body', 'cross-repository',
'non-default branch', 'explicit user authorization',
'commit is unpublished', 'tree is identical',
'PR body', 'not general permission to amend'):
self.assertIn(clause, common)
self.assertIn('repository-cache.md#commit-message-reference-safety', skill)