Add reviewer_worktree proofs that block stash/reset/checkout -- cleanup of unrelated local files, require scratch worktree reporting when the main tree is dirty outside PR scope, and integrate the gate into final report grading and Controller Handoff review fields. Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
1338 lines
50 KiB
Python
1338 lines
50 KiB
Python
"""Fail-closed proof helpers for the blind PR queue review workflow (#173).
|
||
|
||
A blind queue review must *prove* its preconditions instead of asserting
|
||
them. Each helper here evaluates one proof from issue #173's required
|
||
behaviors and returns a plain dict verdict; ``build_final_report`` combines
|
||
them and only awards an "A" grade when every proof is present. Missing
|
||
evidence always downgrades or blocks — never upgrades.
|
||
|
||
These helpers are pure (no network, no git calls): the workflow gathers the
|
||
raw facts (Gitea PR head SHA, ``git rev-parse HEAD``, listing pagination
|
||
state, pytest output, whoami results) and passes them in, so the same logic
|
||
is usable from prompts, harness assertions, and tests. The MCP-level gates
|
||
(mcp-control-plane #68/#76) remain authoritative for mutations; nothing
|
||
here weakens or replaces them.
|
||
"""
|
||
|
||
import re
|
||
|
||
import issue_duplicate_gate
|
||
from reviewer_worktree import assess_reviewer_worktree_proof
|
||
|
||
_FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
|
||
|
||
|
||
# Repo name disambiguation rules for blind PR queue review.
|
||
# User phrases referencing the "MCP Gitea tool" (or similar) must resolve to
|
||
# Gitea-Tools repo, not be confused with mcp-control-plane.
|
||
# If ambiguous (e.g. just "open PRs"), check both configured repos.
|
||
REPO_ALIASES = {
|
||
"gitea-tools": "Scaled-Tech-Consulting/Gitea-Tools",
|
||
"gitea tool": "Scaled-Tech-Consulting/Gitea-Tools",
|
||
"mcp gitea tool": "Scaled-Tech-Consulting/Gitea-Tools",
|
||
"gitea mcp tool": "Scaled-Tech-Consulting/Gitea-Tools",
|
||
"gitea-tools repo": "Scaled-Tech-Consulting/Gitea-Tools",
|
||
"mcp-control-plane": "Scaled-Tech-Consulting/mcp-control-plane",
|
||
"mcp control plane": "Scaled-Tech-Consulting/mcp-control-plane",
|
||
}
|
||
|
||
|
||
def resolve_repos_from_user_reference(
|
||
reference: str, configured: list[str] | None = None
|
||
) -> list[str]:
|
||
"""Resolve a user reference string to the list of target repos to inventory.
|
||
|
||
- Exact aliases for Gitea-Tools map only to Gitea-Tools.
|
||
- mcp-control-plane aliases map only to it.
|
||
- Empty, ambiguous, or general "open PRs" default to all configured repos
|
||
(both by default).
|
||
- Returns subset of configured; never invents new repos.
|
||
"""
|
||
if configured is None:
|
||
configured = [
|
||
"Scaled-Tech-Consulting/Gitea-Tools",
|
||
"Scaled-Tech-Consulting/mcp-control-plane",
|
||
]
|
||
if not reference or not reference.strip():
|
||
return list(configured)
|
||
|
||
ref_lower = reference.lower()
|
||
matched = []
|
||
for alias, full_name in REPO_ALIASES.items():
|
||
if alias in ref_lower:
|
||
if full_name not in matched:
|
||
matched.append(full_name)
|
||
if matched:
|
||
# return only the matched ones that are in configured, preserving order
|
||
return [r for r in configured if r in matched]
|
||
# no specific alias match → check all (complete inventory required)
|
||
return list(configured)
|
||
|
||
|
||
SAFE_NEXT_ACTION_UNKNOWN_CONTAMINATION = (
|
||
"evidence missing: report contamination as unknown and "
|
||
"choose another PR or stop"
|
||
)
|
||
|
||
|
||
def _normalize_ref(ref):
|
||
"""Normalize a git ref for base comparison.
|
||
|
||
Strips ``refs/heads/`` and ``refs/remotes/<remote>/`` prefixes and a
|
||
plain ``<remote>/`` prefix so ``master``, ``prgs/master`` and
|
||
``refs/remotes/prgs/master`` all compare equal. Returns '' for empty
|
||
input (which then fails closed in the caller).
|
||
"""
|
||
ref = (ref or "").strip()
|
||
if ref.startswith("refs/heads/"):
|
||
ref = ref[len("refs/heads/"):]
|
||
elif ref.startswith("refs/remotes/"):
|
||
ref = ref[len("refs/remotes/"):]
|
||
ref = ref.split("/", 1)[1] if "/" in ref else ref
|
||
elif "/" in ref:
|
||
# Remote-tracking shorthand like 'prgs/master'.
|
||
ref = ref.split("/", 1)[1]
|
||
return ref
|
||
|
||
|
||
def verify_pinned_head_checkout(pinned_head_sha, local_head_sha,
|
||
pr_base_ref, diff_base_ref):
|
||
"""Required behaviors 1–2: prove HEAD == pinned PR head, correct base.
|
||
|
||
Both SHAs must be full 40-hex and identical — an abbreviated or prefix
|
||
match is not proof. The diff base must normalize to the PR base branch.
|
||
Returns {'proven', 'block', 'reasons', 'pinned_head_sha',
|
||
'local_head_sha'}; ``block`` is True whenever the proof fails, meaning
|
||
review and merge must stop before proceeding.
|
||
"""
|
||
reasons = []
|
||
pinned = (pinned_head_sha or "").strip().lower()
|
||
local = (local_head_sha or "").strip().lower()
|
||
|
||
if not pinned:
|
||
reasons.append("pinned PR head SHA missing; fail closed")
|
||
elif not _FULL_SHA.match(pinned):
|
||
reasons.append("pinned PR head SHA is not a full 40-hex SHA; fail closed")
|
||
|
||
if not local:
|
||
reasons.append("local checkout HEAD SHA missing; fail closed")
|
||
elif not _FULL_SHA.match(local):
|
||
reasons.append(
|
||
"local checkout HEAD SHA is not a full 40-hex SHA "
|
||
"(abbreviated SHAs are not proof); fail closed"
|
||
)
|
||
|
||
if pinned and local and _FULL_SHA.match(pinned) and _FULL_SHA.match(local):
|
||
if pinned != local:
|
||
reasons.append(
|
||
"local checkout HEAD does not match the pinned PR head SHA; "
|
||
"stop before review/merge"
|
||
)
|
||
|
||
base = _normalize_ref(pr_base_ref)
|
||
diff_base = _normalize_ref(diff_base_ref)
|
||
if not base:
|
||
reasons.append("PR base ref missing; fail closed")
|
||
if not diff_base:
|
||
reasons.append("diff base ref missing; fail closed")
|
||
if base and diff_base and base != diff_base:
|
||
reasons.append(
|
||
f"diff base '{diff_base}' is not the PR base branch '{base}'"
|
||
)
|
||
|
||
proven = not reasons
|
||
return {
|
||
"proven": proven,
|
||
"block": not proven,
|
||
"reasons": reasons,
|
||
"pinned_head_sha": pinned or None,
|
||
"local_head_sha": local or None,
|
||
}
|
||
|
||
|
||
def assess_inventory_completeness(repo_reports, required_repos):
|
||
"""Required behavior 3: the queue inventory must prove completeness.
|
||
|
||
*repo_reports* is a list of dicts, one per repository listed:
|
||
``{'repo', 'state_filter', 'pagination_complete', 'open_pr_count'}``.
|
||
The inventory is complete only when every required repository has a
|
||
report that states its filter, proves pagination was handled, and gives
|
||
a total open-PR count. ``can_claim_exhaustive`` — permission to say
|
||
"these are the only open PRs" — is granted only for a complete
|
||
inventory.
|
||
"""
|
||
reasons = []
|
||
by_repo = {}
|
||
for report in repo_reports or []:
|
||
name = (report.get("repo") or "").strip()
|
||
if name:
|
||
by_repo[name] = report
|
||
|
||
totals = {}
|
||
for repo in required_repos:
|
||
report = by_repo.get(repo)
|
||
if report is None:
|
||
reasons.append(f"repository '{repo}' was not inventoried")
|
||
continue
|
||
if not (report.get("state_filter") or "").strip():
|
||
reasons.append(f"repository '{repo}': PR state filter not stated")
|
||
if report.get("pagination_complete") is not True:
|
||
reasons.append(
|
||
f"repository '{repo}': pagination not proven complete; "
|
||
"a truncated listing cannot support an exhaustive claim"
|
||
)
|
||
count = report.get("open_pr_count")
|
||
if not isinstance(count, int) or count < 0:
|
||
reasons.append(
|
||
f"repository '{repo}': total open PR count not reported"
|
||
)
|
||
else:
|
||
totals[repo] = count
|
||
|
||
complete = not reasons and bool(required_repos)
|
||
if not required_repos:
|
||
reasons.append("no required repositories configured; fail closed")
|
||
return {
|
||
"complete": complete,
|
||
"can_claim_exhaustive": complete,
|
||
"reasons": reasons,
|
||
"total_open_prs": totals,
|
||
}
|
||
|
||
|
||
def assess_validation_report(report):
|
||
"""Required behavior 4: validation reporting needs exact evidence.
|
||
|
||
*report* keys: ``command``, ``output_read``, ``result`` ('pass'/'fail'),
|
||
``passed``/``failed``/``skipped`` counts, ``ignored_paths`` (each with a
|
||
``justification``), ``canonical_command``, ``deviation_justification``.
|
||
|
||
Verdicts:
|
||
- 'invalid' — the result may not be claimed at all (no command stated,
|
||
or the command output was never read).
|
||
- 'weak' — claimable but downgraded (missing counts, unjustified
|
||
ignored paths, unexplained deviation from the canonical command).
|
||
- 'strong' — full evidence.
|
||
"""
|
||
reasons = []
|
||
command = (report.get("command") or "").strip()
|
||
|
||
if not command:
|
||
return {
|
||
"verdict": "invalid",
|
||
"claimable": False,
|
||
"reasons": ["no validation command stated; result cannot be claimed"],
|
||
}
|
||
if report.get("output_read") is not True:
|
||
return {
|
||
"verdict": "invalid",
|
||
"claimable": False,
|
||
"reasons": [
|
||
"command output was not read to completion; a validation "
|
||
"result cannot be claimed"
|
||
],
|
||
}
|
||
|
||
counts = [report.get("passed"), report.get("failed"), report.get("skipped")]
|
||
if any(not isinstance(c, int) for c in counts):
|
||
reasons.append(
|
||
"test counts (passed/failed/skipped) missing; report is incomplete"
|
||
)
|
||
|
||
for ignored in report.get("ignored_paths") or []:
|
||
path = ignored.get("path") or "<unnamed>"
|
||
if not (ignored.get("justification") or "").strip():
|
||
reasons.append(
|
||
f"ignored path '{path}' has no justification for why it is "
|
||
"safe to ignore"
|
||
)
|
||
|
||
canonical = (report.get("canonical_command") or "").strip()
|
||
if canonical and command != canonical:
|
||
if not (report.get("deviation_justification") or "").strip():
|
||
reasons.append(
|
||
"command differs from the repository's canonical validation "
|
||
"command and the deviation is not justified"
|
||
)
|
||
|
||
verdict = "strong" if not reasons else "weak"
|
||
return {"verdict": verdict, "claimable": True, "reasons": reasons}
|
||
|
||
|
||
def assess_self_review_contamination(reviewer_identity, pr_author,
|
||
session_authored=None,
|
||
evidence_source=None):
|
||
"""Required behavior 5: contamination is evidence-backed, never assumed.
|
||
|
||
Distinguishes the two cases the issue calls out:
|
||
- reviewer identity == PR author → contaminated (the identities
|
||
themselves are the evidence);
|
||
- same-session authorship → contaminated only with an explicit
|
||
``evidence_source``; a bare claim (either way) yields 'unknown'.
|
||
|
||
'unknown' is a stop signal: choose another PR or stop — it is never
|
||
silently treated as clean or as contaminated.
|
||
"""
|
||
reviewer = (reviewer_identity or "").strip()
|
||
author = (pr_author or "").strip()
|
||
evidence = (evidence_source or "").strip()
|
||
|
||
if not reviewer or not author:
|
||
return {
|
||
"status": "unknown",
|
||
"reasons": ["reviewer identity or PR author unknown; fail closed"],
|
||
"safe_next_action": SAFE_NEXT_ACTION_UNKNOWN_CONTAMINATION,
|
||
}
|
||
|
||
if reviewer == author:
|
||
return {
|
||
"status": "contaminated",
|
||
"reasons": [
|
||
f"authenticated reviewer '{reviewer}' is the PR author; "
|
||
"self-review is blocked"
|
||
],
|
||
"safe_next_action": "choose another PR or stop",
|
||
}
|
||
|
||
if session_authored is True:
|
||
if evidence:
|
||
return {
|
||
"status": "contaminated",
|
||
"reasons": [
|
||
"this session authored/touched the PR branch "
|
||
f"(evidence: {evidence})"
|
||
],
|
||
"safe_next_action": "choose another PR or stop",
|
||
}
|
||
return {
|
||
"status": "unknown",
|
||
"reasons": [
|
||
"same-session authorship was claimed without an evidence "
|
||
"source; contamination may not be declared by assumption"
|
||
],
|
||
"safe_next_action": SAFE_NEXT_ACTION_UNKNOWN_CONTAMINATION,
|
||
}
|
||
|
||
if session_authored is False and evidence:
|
||
return {
|
||
"status": "clean",
|
||
"reasons": [
|
||
f"reviewer '{reviewer}' differs from author '{author}' and "
|
||
f"session non-authorship is evidenced ({evidence})"
|
||
],
|
||
"safe_next_action": "proceed",
|
||
}
|
||
|
||
return {
|
||
"status": "unknown",
|
||
"reasons": [
|
||
"session authorship was not established with evidence; "
|
||
"contamination status is unknown"
|
||
],
|
||
"safe_next_action": SAFE_NEXT_ACTION_UNKNOWN_CONTAMINATION,
|
||
}
|
||
|
||
|
||
def assess_capability_evidence(capability_claims):
|
||
"""#179 gap 1: a capability claim needs exact evidence, not assertion.
|
||
|
||
*capability_claims* is a list of ``{'task', 'allowed',
|
||
'evidence_source'}`` dicts, one per capability the report claims (e.g.
|
||
review_pr, merge_pr). Each claim must name its task, be allowed, and
|
||
cite an exact evidence source (``gitea_resolve_task_capability`` output
|
||
or equivalent runtime-context evidence). No claims at all fails closed.
|
||
"""
|
||
reasons = []
|
||
claims = capability_claims or []
|
||
if not claims:
|
||
reasons.append(
|
||
"no capability evidence provided; capability checks may not be "
|
||
"claimed as passed"
|
||
)
|
||
for claim in claims:
|
||
task = (claim.get("task") or "").strip() or "<unnamed task>"
|
||
if claim.get("allowed") is not True:
|
||
reasons.append(
|
||
f"capability '{task}' is not proven allowed; fail closed"
|
||
)
|
||
if not (claim.get("evidence_source") or "").strip():
|
||
reasons.append(
|
||
f"capability '{task}' claimed without exact evidence "
|
||
"(cite gitea_resolve_task_capability output or equivalent)"
|
||
)
|
||
proven = not reasons
|
||
return {"proven": proven, "reasons": reasons, "claims": len(claims)}
|
||
|
||
|
||
def assess_sweep_evidence(sweep):
|
||
"""#179 gap 2: secret/provenance sweeps must state exact method + scope.
|
||
|
||
*sweep* keys: ``command`` (the exact command, script, grep pattern, or
|
||
named sweep method), ``scope`` (what was scanned, e.g. 'full PR diff
|
||
against prgs/master'), ``clean`` (bool result). A vague summary without
|
||
the exact method is downgraded; a missing sweep fails closed.
|
||
"""
|
||
if not sweep:
|
||
return {
|
||
"verdict": "missing",
|
||
"proven": False,
|
||
"reasons": ["no secret/provenance sweep reported; fail closed"],
|
||
}
|
||
reasons = []
|
||
if not (sweep.get("command") or "").strip():
|
||
reasons.append(
|
||
"sweep method/command not stated exactly (command, script, "
|
||
"pattern, or named sweep method required)"
|
||
)
|
||
if not (sweep.get("scope") or "").strip():
|
||
reasons.append("sweep scope not stated (what diff/files were scanned)")
|
||
if not isinstance(sweep.get("clean"), bool):
|
||
reasons.append("sweep result not stated as clean/not-clean")
|
||
verdict = "exact" if not reasons else "vague"
|
||
return {
|
||
"verdict": verdict,
|
||
"proven": verdict == "exact",
|
||
"reasons": reasons,
|
||
"clean": sweep.get("clean") if isinstance(sweep.get("clean"), bool)
|
||
else None,
|
||
}
|
||
|
||
|
||
def assess_live_state_recheck(recheck):
|
||
"""#179 gap 3: explicit live-state recheck before review/merge mutation.
|
||
|
||
*recheck* keys: ``pr_state``, ``pinned_head_sha``, ``live_head_sha``,
|
||
``pinned_base_ref``, ``live_base_ref``, ``blocking_change_requests``.
|
||
Proven only when the PR is still open, the live head equals the pinned
|
||
head (full 40-hex), the base branch is unchanged, and blocking review
|
||
state was checked and is absent. Not performing the recheck fails
|
||
closed and blocks mutation.
|
||
"""
|
||
if not recheck:
|
||
return {
|
||
"proven": False,
|
||
"block": True,
|
||
"reasons": [
|
||
"final live-state recheck not performed before mutation; "
|
||
"fail closed"
|
||
],
|
||
}
|
||
reasons = []
|
||
if (recheck.get("pr_state") or "").strip().lower() != "open":
|
||
reasons.append(
|
||
f"PR state is '{recheck.get('pr_state')}', not open; stop"
|
||
)
|
||
|
||
pinned = (recheck.get("pinned_head_sha") or "").strip().lower()
|
||
live = (recheck.get("live_head_sha") or "").strip().lower()
|
||
if not (_FULL_SHA.match(pinned) and _FULL_SHA.match(live)):
|
||
reasons.append(
|
||
"pinned/live head SHAs missing or not full 40-hex; fail closed"
|
||
)
|
||
elif pinned != live:
|
||
reasons.append(
|
||
"live head SHA no longer equals the pinned head; re-pin and "
|
||
"re-validate before mutation"
|
||
)
|
||
|
||
base_pinned = _normalize_ref(recheck.get("pinned_base_ref"))
|
||
base_live = _normalize_ref(recheck.get("live_base_ref"))
|
||
if not base_pinned or not base_live:
|
||
reasons.append("base refs missing from live-state recheck; fail closed")
|
||
elif base_pinned != base_live:
|
||
reasons.append(
|
||
f"base branch changed from '{base_pinned}' to '{base_live}'"
|
||
)
|
||
|
||
blocking = recheck.get("blocking_change_requests")
|
||
if blocking is None:
|
||
reasons.append(
|
||
"blocking review state not checked; fail closed"
|
||
)
|
||
elif blocking:
|
||
reasons.append(
|
||
"an undismissed REQUEST_CHANGES / blocking review state remains "
|
||
"unresolved"
|
||
)
|
||
|
||
proven = not reasons
|
||
return {"proven": proven, "block": not proven, "reasons": reasons}
|
||
|
||
|
||
def assess_role_boundary(proof=None, *, task_role=None, namespaces_used=None,
|
||
justification=None):
|
||
"""Assess reviewer/author role separation for blind queue workflows.
|
||
|
||
Issue #175 blocks a reviewer queue task from silently becoming author
|
||
implementation work. Issue #179 also requires reviewer workflows to
|
||
report namespace use and justify any foreign namespace calls. This helper
|
||
accepts both forms:
|
||
|
||
- the #175 dict proof with mutation details, or
|
||
- the #179 keyword form: ``task_role``, ``namespaces_used``,
|
||
``justification``.
|
||
"""
|
||
if proof is None:
|
||
namespaces_reported = namespaces_used is not None
|
||
namespaces = list(namespaces_used or [])
|
||
proof = {
|
||
"task_role": task_role,
|
||
"reviewer_namespace_used": any(
|
||
"reviewer" in (namespace or "").lower()
|
||
for namespace in namespaces
|
||
),
|
||
"author_namespace_used": any(
|
||
"author" in (namespace or "").lower()
|
||
for namespace in namespaces
|
||
),
|
||
"mixed_namespace_justification": justification,
|
||
"author_mutations": [],
|
||
"review_mutations": [],
|
||
"_namespaces_used": namespaces,
|
||
"_namespaces_reported": namespaces_reported,
|
||
}
|
||
else:
|
||
proof = dict(proof or {})
|
||
|
||
task_role = (proof.get("task_role") or "").strip().lower()
|
||
task_kind = (proof.get("task_kind") or "").strip().lower()
|
||
author_mutations = list(proof.get("author_mutations") or [])
|
||
review_mutations = list(proof.get("review_mutations") or [])
|
||
reviewer_used = bool(proof.get("reviewer_namespace_used"))
|
||
author_used = bool(proof.get("author_namespace_used"))
|
||
authorized = bool(proof.get("operator_authorized_author_work"))
|
||
mixed_justification = (
|
||
proof.get("mixed_namespace_justification") or ""
|
||
).strip()
|
||
scratch_claimed = bool(proof.get("scratch_evidence_claimed"))
|
||
scratch_durable = bool(proof.get("scratch_evidence_durable"))
|
||
|
||
reasons = []
|
||
violations = []
|
||
|
||
if task_role not in {"reviewer", "author"}:
|
||
reasons.append("task role missing or unknown; role boundary unproven")
|
||
if proof.get("_namespaces_reported") is False:
|
||
reasons.append("namespaces used were not reported; fail closed")
|
||
|
||
if task_role == "reviewer":
|
||
if author_mutations and not authorized:
|
||
violations.append(
|
||
"reviewer task performed author mutations without explicit "
|
||
"operator authorization"
|
||
)
|
||
if author_used and not mixed_justification:
|
||
reasons.append(
|
||
"reviewer task used author namespace without an explicit "
|
||
"justification"
|
||
)
|
||
if task_kind == "blind_pr_queue_review" and author_mutations:
|
||
if not authorized:
|
||
violations.append(
|
||
"blind PR queue review silently pivoted into author "
|
||
"implementation"
|
||
)
|
||
elif task_role == "author":
|
||
if review_mutations:
|
||
violations.append(
|
||
"author task performed reviewer-only mutations"
|
||
)
|
||
|
||
if reviewer_used and author_used and not mixed_justification:
|
||
reasons.append(
|
||
"mixed reviewer+author namespace use was not reported as a "
|
||
"role-boundary event"
|
||
)
|
||
|
||
if scratch_claimed and not scratch_durable:
|
||
reasons.append(
|
||
"scratch-only notes were claimed as durable evidence"
|
||
)
|
||
|
||
if violations:
|
||
status = "violation"
|
||
safe_next_action = "stop; report role-boundary violation"
|
||
elif reasons:
|
||
status = "warning"
|
||
safe_next_action = "downgrade final report; do not claim A-level proof"
|
||
else:
|
||
status = "clean"
|
||
safe_next_action = "proceed"
|
||
|
||
namespaces = proof.get("_namespaces_used")
|
||
if namespaces is None:
|
||
namespaces = []
|
||
if reviewer_used:
|
||
namespaces.append("gitea-reviewer")
|
||
if author_used:
|
||
namespaces.append("gitea-author")
|
||
foreign = [
|
||
namespace for namespace in namespaces
|
||
if task_role and task_role not in (namespace or "").lower()
|
||
]
|
||
|
||
return {
|
||
"status": status,
|
||
"clean": status == "clean",
|
||
"proven": status == "clean",
|
||
"reasons": reasons,
|
||
"violations": violations,
|
||
"safe_next_action": safe_next_action,
|
||
"foreign_namespaces": foreign,
|
||
"justified": bool(mixed_justification),
|
||
}
|
||
|
||
|
||
def assess_review_mutation_final_report(report_text, review_decision_lock):
|
||
"""Require final reports to list exactly one live review mutation.
|
||
|
||
Two mutations are allowed only when an operator-approved correction flow
|
||
was invoked and explained in the report.
|
||
"""
|
||
lock = review_decision_lock or {}
|
||
text = report_text or ""
|
||
lower = text.lower()
|
||
mutations = list(lock.get("live_mutations") or [])
|
||
missing = []
|
||
|
||
count = len(mutations)
|
||
if count == 0:
|
||
if any(term in lower for term in ("submitted 'approve'", "submitted 'request_changes'", "live review mutation")):
|
||
missing.append("review mutation count")
|
||
elif count == 1:
|
||
m = mutations[0]
|
||
action = m.get("action")
|
||
pr_number = m.get("pr_number")
|
||
if action and action.lower() not in lower:
|
||
missing.append("review mutation action")
|
||
if pr_number is not None and f"#{pr_number}" not in lower and f"pr {pr_number}" not in lower:
|
||
missing.append("review mutation PR number")
|
||
elif count > 1:
|
||
if not lock.get("correction_authorized") and "correction" not in lower:
|
||
missing.append("correction flow explanation")
|
||
if "review mutations:" not in lower and "review mutation" not in lower:
|
||
missing.append("review mutation listing")
|
||
|
||
if missing:
|
||
return {
|
||
"complete": False,
|
||
"downgraded": True,
|
||
"missing_fields": missing,
|
||
"reasons": [
|
||
f"final report missing review-mutation field: {field}"
|
||
for field in missing
|
||
],
|
||
}
|
||
return {
|
||
"complete": True,
|
||
"downgraded": False,
|
||
"missing_fields": [],
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
def build_final_report(checkout_proof, inventory, validation, contamination,
|
||
identity_eligible, merge_performed,
|
||
issue_status_verified,
|
||
capability_evidence=None, sweep=None, live_state=None,
|
||
role_boundary=None, review_mutation=None,
|
||
report_text=None, review_decision_lock=None,
|
||
controller_handoff=None, capability_proof=None,
|
||
sweep_proof=None, worktree_proof=None):
|
||
"""Required behavior 6 + acceptance criteria: one report, distinct proofs.
|
||
|
||
Combines the individual proof verdicts into the final-report fields the
|
||
issue requires, computes ``merge_allowed`` from the proofs, and grades
|
||
the run:
|
||
|
||
- 'A' — every proof present (merge itself is optional).
|
||
- 'downgraded' — one or more proofs missing/weak; do not merge.
|
||
- 'blocked' — a *violation*: a merge was claimed although the proofs
|
||
did not allow one.
|
||
|
||
#179 raises the A bar: the report must also carry exact capability
|
||
evidence (``assess_capability_evidence``), an exact secret/provenance
|
||
sweep (``assess_sweep_evidence``), a pre-mutation live-state recheck
|
||
(``assess_live_state_recheck`` — also required for ``merge_allowed``),
|
||
and a clean role boundary (``assess_role_boundary``). Omitting any of
|
||
them downgrades; a merge without the live recheck is a violation.
|
||
|
||
#211: the report must also carry the review mutation proof (``assess_review_mutation_final_report``).
|
||
"""
|
||
if review_mutation is None and report_text is not None:
|
||
review_mutation = assess_review_mutation_final_report(
|
||
report_text, review_decision_lock
|
||
)
|
||
|
||
contamination_status = contamination.get("status", "unknown")
|
||
checkout_proven = bool(checkout_proof.get("proven"))
|
||
validation_claimable = bool(validation.get("claimable"))
|
||
validation_strong = validation.get("verdict") == "strong"
|
||
role_boundary = role_boundary or {
|
||
"status": "warning",
|
||
"reasons": ["role-boundary proof missing"],
|
||
"violations": [],
|
||
}
|
||
role_status = role_boundary.get("status", "warning")
|
||
handoff = _normalize_controller_handoff_proof(controller_handoff)
|
||
capability_proof = capability_proof or {
|
||
"proven": False,
|
||
"reasons": ["capability proof not provided"],
|
||
}
|
||
sweep_proof = sweep_proof or {
|
||
"proven": False,
|
||
"verdict": "invalid",
|
||
"reasons": ["secret/provenance sweep proof not provided"],
|
||
}
|
||
|
||
capability_evidence = capability_evidence or {
|
||
"proven": False,
|
||
"reasons": ["capability evidence not provided (#179)"],
|
||
}
|
||
sweep = sweep or {
|
||
"verdict": "missing",
|
||
"proven": False,
|
||
"reasons": ["secret/provenance sweep evidence not provided (#179)"],
|
||
}
|
||
live_state = live_state or {
|
||
"proven": False,
|
||
"block": True,
|
||
"reasons": ["pre-mutation live-state recheck not provided (#179)"],
|
||
}
|
||
role_boundary = role_boundary or {
|
||
"proven": False,
|
||
"reasons": ["role-boundary/namespace usage not reported (#179)"],
|
||
}
|
||
review_mutation = review_mutation or {
|
||
"complete": False,
|
||
"downgraded": True,
|
||
"reasons": ["review mutation proof not provided (#211)"],
|
||
}
|
||
if worktree_proof is not None:
|
||
worktree = assess_reviewer_worktree_proof(worktree_proof)
|
||
else:
|
||
worktree = {
|
||
"proven": False,
|
||
"block": True,
|
||
"reasons": ["reviewer worktree proof not provided (#233)"],
|
||
}
|
||
|
||
capability_proven = bool(capability_evidence.get("proven"))
|
||
sweep_proven = bool(sweep.get("proven"))
|
||
live_state_proven = bool(live_state.get("proven"))
|
||
role_boundary_clean = bool(role_boundary.get("proven"))
|
||
review_mutation_complete = bool(review_mutation.get("complete"))
|
||
|
||
review_mutation = review_mutation or {
|
||
"complete": False,
|
||
"reasons": ["review mutation proof missing"],
|
||
}
|
||
review_mutation_complete = bool(review_mutation.get("complete"))
|
||
worktree_proven = bool(worktree.get("proven"))
|
||
|
||
downgrade_reasons = []
|
||
if not identity_eligible:
|
||
downgrade_reasons.append("identity/profile not eligible for review")
|
||
if not inventory.get("complete"):
|
||
downgrade_reasons.append("open-PR inventory completeness not proven")
|
||
downgrade_reasons.extend(inventory.get("reasons", []))
|
||
if not checkout_proven:
|
||
downgrade_reasons.append("pinned-head checkout not proven")
|
||
downgrade_reasons.extend(checkout_proof.get("reasons", []))
|
||
if not validation_strong:
|
||
downgrade_reasons.append(
|
||
f"validation evidence is {validation.get('verdict', 'missing')}"
|
||
)
|
||
downgrade_reasons.extend(validation.get("reasons", []))
|
||
if contamination_status != "clean":
|
||
downgrade_reasons.append(
|
||
f"session contamination status is '{contamination_status}'"
|
||
)
|
||
if role_status != "clean":
|
||
downgrade_reasons.append(f"role boundary status is '{role_status}'")
|
||
downgrade_reasons.extend(role_boundary.get("reasons", []))
|
||
if not issue_status_verified:
|
||
downgrade_reasons.append("linked issue status not verified")
|
||
if not handoff["complete"]:
|
||
downgrade_reasons.append("Controller Handoff missing or incomplete")
|
||
downgrade_reasons.extend(handoff.get("reasons", []))
|
||
if not capability_proof.get("proven"):
|
||
downgrade_reasons.append("exact mutation capability proof missing")
|
||
downgrade_reasons.extend(capability_proof.get("reasons", []))
|
||
if not sweep_proof.get("proven"):
|
||
downgrade_reasons.append("exact secret/provenance sweep proof missing")
|
||
downgrade_reasons.extend(sweep_proof.get("reasons", []))
|
||
if not capability_proven:
|
||
downgrade_reasons.append("exact capability evidence missing (#179)")
|
||
downgrade_reasons.extend(capability_evidence.get("reasons", []))
|
||
if not sweep_proven:
|
||
downgrade_reasons.append(
|
||
f"secret/provenance sweep evidence is "
|
||
f"{sweep.get('verdict', 'missing')} (#179)"
|
||
)
|
||
downgrade_reasons.extend(sweep.get("reasons", []))
|
||
if not live_state_proven:
|
||
downgrade_reasons.append(
|
||
"pre-mutation live-state recheck missing or failed (#179)"
|
||
)
|
||
downgrade_reasons.extend(live_state.get("reasons", []))
|
||
if not role_boundary_clean:
|
||
downgrade_reasons.append("role/namespace boundary not clean (#179)")
|
||
downgrade_reasons.extend(role_boundary.get("reasons", []))
|
||
if not review_mutation_complete:
|
||
downgrade_reasons.append("review mutation proof missing or incomplete (#211)")
|
||
downgrade_reasons.extend(review_mutation.get("reasons", []))
|
||
if not worktree_proven:
|
||
downgrade_reasons.append(
|
||
"reviewer worktree safety proof missing or failed (#233)"
|
||
)
|
||
downgrade_reasons.extend(worktree.get("reasons", []))
|
||
|
||
merge_allowed = (
|
||
identity_eligible
|
||
and checkout_proven
|
||
and contamination_status == "clean"
|
||
and role_status == "clean"
|
||
and validation_claimable
|
||
and validation.get("verdict") != "invalid"
|
||
# #179: no merge without a proven final live-state recheck.
|
||
and live_state_proven
|
||
and worktree_proven
|
||
)
|
||
|
||
violations = []
|
||
if merge_performed and not merge_allowed:
|
||
violations.append(
|
||
"merge was performed/claimed although the proofs did not allow "
|
||
"one; this run is blocked, not graded"
|
||
)
|
||
violations.extend(role_boundary.get("violations", []))
|
||
|
||
if violations:
|
||
grade = "blocked"
|
||
elif downgrade_reasons:
|
||
grade = "downgraded"
|
||
else:
|
||
grade = "A"
|
||
|
||
return {
|
||
"grade": grade,
|
||
"violations": violations,
|
||
"downgrade_reasons": downgrade_reasons,
|
||
"identity_eligible": bool(identity_eligible),
|
||
"pr_author_distinct_from_reviewer":
|
||
contamination_status in ("clean",),
|
||
"session_contamination": contamination_status,
|
||
"role_boundary": role_status,
|
||
"inventory_complete": bool(inventory.get("complete")),
|
||
"validated_on_pinned_head": checkout_proven and validation_claimable,
|
||
"validation_passed":
|
||
validation_claimable and validation.get("verdict") != "invalid",
|
||
"validation_verdict": validation.get("verdict"),
|
||
"merge_allowed": merge_allowed,
|
||
"merge_performed": bool(merge_performed),
|
||
"issue_status_verified": bool(issue_status_verified),
|
||
"controller_handoff_present": handoff["present"],
|
||
"controller_handoff_complete": handoff["complete"],
|
||
"capability_proof": bool(capability_proof.get("proven")),
|
||
"secret_sweep_proof": bool(sweep_proof.get("proven")),
|
||
"capability_evidence_proven": capability_proven,
|
||
"sweep_verdict": sweep.get("verdict"),
|
||
"live_state_recheck_proven": live_state_proven,
|
||
"role_boundary_clean": role_boundary_clean,
|
||
"review_mutation_complete": review_mutation_complete,
|
||
"worktree_proof_proven": worktree_proven,
|
||
"worktree_scratch_used": bool(worktree.get("scratch_used")),
|
||
"unrelated_mutations_avoided": bool(
|
||
worktree.get("unrelated_mutations_avoided")
|
||
),
|
||
}
|
||
|
||
|
||
def _normalize_controller_handoff_proof(controller_handoff):
|
||
"""Normalize old boolean handoff proof and rich #182 handoff verdicts."""
|
||
if controller_handoff is None:
|
||
return {
|
||
"present": False,
|
||
"complete": False,
|
||
"reasons": ["Controller Handoff proof not provided"],
|
||
}
|
||
if isinstance(controller_handoff, str):
|
||
controller_handoff = assess_controller_handoff(controller_handoff)
|
||
if not isinstance(controller_handoff, dict):
|
||
return {
|
||
"present": False,
|
||
"complete": False,
|
||
"reasons": ["Controller Handoff proof has invalid type"],
|
||
}
|
||
if "verdict" in controller_handoff:
|
||
verdict = controller_handoff.get("verdict")
|
||
return {
|
||
"present": verdict in {"complete", "incomplete"},
|
||
"complete": verdict == "complete",
|
||
"reasons": list(controller_handoff.get("reasons") or []),
|
||
}
|
||
present = bool(controller_handoff.get("present"))
|
||
return {
|
||
"present": present,
|
||
"complete": present,
|
||
"reasons": list(controller_handoff.get("reasons") or []),
|
||
}
|
||
|
||
|
||
def assess_capability_proof(resolved_capabilities: dict) -> dict:
|
||
"""Every mutation must have exact capability proof (#183)."""
|
||
reasons = []
|
||
if not resolved_capabilities:
|
||
return {
|
||
"proven": False,
|
||
"reasons": ["no capability proof resolved; fail closed"],
|
||
}
|
||
for task, cap in resolved_capabilities.items():
|
||
allowed = cap.get("allowed_in_current_session")
|
||
op = cap.get("required_operation_permission")
|
||
if not allowed:
|
||
reasons.append(
|
||
f"task '{task}' requires permission '{op}' which is not "
|
||
"allowed in current session"
|
||
)
|
||
if (
|
||
"unknown" in str(cap.get("requested_task", "")).lower()
|
||
or cap.get("allowed_in_current_session") is None
|
||
):
|
||
reasons.append(
|
||
f"task '{task}' could not be resolved to a known capability"
|
||
)
|
||
return {"proven": not reasons, "reasons": reasons}
|
||
|
||
|
||
def assess_secret_sweep(sweep_report: dict) -> dict:
|
||
"""Secret/provenance sweeps must state exact command/method and scope."""
|
||
if not sweep_report:
|
||
return {
|
||
"proven": False,
|
||
"verdict": "invalid",
|
||
"reasons": ["no secret/provenance sweep report provided"],
|
||
}
|
||
reasons = []
|
||
method = (sweep_report.get("method") or "").strip()
|
||
scope = (sweep_report.get("scope") or "").strip()
|
||
if not method:
|
||
reasons.append(
|
||
"secret sweep report missing exact scan command, pattern, or method"
|
||
)
|
||
if not scope:
|
||
reasons.append("secret sweep report missing scanned scope")
|
||
if sweep_report.get("clean") is not True:
|
||
reasons.append("secret sweep report did not confirm diff is clean")
|
||
|
||
verdict = "weak" if reasons else "strong"
|
||
return {
|
||
"proven": not reasons and sweep_report.get("clean") is True,
|
||
"verdict": verdict,
|
||
"reasons": reasons,
|
||
}
|
||
|
||
|
||
def assess_author_pr_report(pr_report: dict) -> dict:
|
||
"""Author PR reports must include PR number, branch, and exact head SHA."""
|
||
if not pr_report:
|
||
return {
|
||
"complete": False,
|
||
"reasons": ["no PR report provided"],
|
||
}
|
||
reasons = []
|
||
pr_number = pr_report.get("pr_number")
|
||
branch = (pr_report.get("branch") or "").strip()
|
||
head_sha = (pr_report.get("head_sha") or "").strip()
|
||
|
||
if not isinstance(pr_number, int) or pr_number <= 0:
|
||
reasons.append("PR number is missing or invalid")
|
||
if not branch:
|
||
reasons.append("PR branch name is missing")
|
||
if not head_sha:
|
||
reasons.append("PR head SHA is missing")
|
||
elif not _FULL_SHA.match(head_sha):
|
||
reasons.append("PR head SHA is not a full 40-hex commit SHA")
|
||
|
||
return {
|
||
"complete": not reasons,
|
||
"reasons": reasons,
|
||
}
|
||
|
||
|
||
# ── Controller Handoff validation (Issue #182) ────────────────────────────────
|
||
#
|
||
# Every final report must end with a compact section titled exactly
|
||
# "Controller Handoff". Each required field is a (canonical name, aliases)
|
||
# pair; a field counts as present when any alias starts a bullet/label line
|
||
# inside the handoff section.
|
||
|
||
HANDOFF_HEADING = "Controller Handoff"
|
||
|
||
HANDOFF_BASE_FIELDS = (
|
||
("Task", ("task",)),
|
||
("Repo", ("repo", "repository", "repo/state")),
|
||
("Role", ("role",)),
|
||
("Identity", ("identity",)),
|
||
("Issue/PR", ("issue/pr", "issues/prs", "issue", "pr")),
|
||
("Branch/SHA", ("branch/sha", "branch", "head sha")),
|
||
("Files changed", ("files changed", "changed", "files")),
|
||
("Validation", ("validation",)),
|
||
("Mutations", ("mutations",)),
|
||
("Workspace mutations", ("workspace mutations",)),
|
||
("Current status", ("current status", "status")),
|
||
("Blockers", ("blockers",)),
|
||
("Next", ("next",)),
|
||
("Safety", ("safety",)),
|
||
)
|
||
|
||
HANDOFF_ROLE_FIELDS = {
|
||
"review": (
|
||
("Selected PR", ("selected pr",)),
|
||
("Reviewer eligibility", ("reviewer eligibility", "eligibility")),
|
||
("Pinned reviewed head", ("pinned reviewed head", "pinned head")),
|
||
("Worktree path", ("worktree path", "starting worktree path")),
|
||
("Worktree dirty", ("worktree dirty", "whether worktree was dirty")),
|
||
("Scratch worktree used", ("scratch worktree used", "scratch clone used",
|
||
"scratch worktree")),
|
||
("Unrelated local mutations", ("unrelated local mutations",
|
||
"unrelated files modified")),
|
||
("Review decision", ("review decision", "decision")),
|
||
("Merge result", ("merge result",)),
|
||
("Linked issue status", ("linked issue status", "linked issue")),
|
||
("Cleanup status", ("cleanup status", "cleanup")),
|
||
),
|
||
"author": (
|
||
("Selected issue", ("selected issue",)),
|
||
("Issue lock proof", ("issue lock proof", "lock before diff")),
|
||
("Claim/comment status", ("claim/comment status", "claim status",
|
||
"claim")),
|
||
("PR number opened", ("pr number opened", "pr opened", "pr number")),
|
||
("No review/merge confirmation", ("no review/merge",
|
||
"no review or merge")),
|
||
),
|
||
"inventory": (
|
||
("Repositories checked", ("repositories checked", "repos checked")),
|
||
("Open PR counts", ("open pr counts", "open pr count",
|
||
"open prs per repo")),
|
||
("Selected PR or reason", ("selected pr", "none selected",
|
||
"reason none selected")),
|
||
("Inventory completeness", ("inventory complete", "inventory scoped",
|
||
"inventory completeness")),
|
||
),
|
||
}
|
||
|
||
|
||
def _handoff_section_lines(report_text):
|
||
"""Return the lines of the Controller Handoff section, or None."""
|
||
lines = (report_text or "").splitlines()
|
||
start = None
|
||
for i, line in enumerate(lines):
|
||
bare = line.strip().lstrip("#").strip().rstrip(":")
|
||
if bare == HANDOFF_HEADING:
|
||
start = i + 1
|
||
break
|
||
if start is None:
|
||
return None
|
||
return lines[start:]
|
||
|
||
|
||
def assess_controller_handoff(report_text, role=None, local_edits=False):
|
||
"""Issue #182: final reports without a Controller Handoff downgrade.
|
||
|
||
Verdicts:
|
||
- 'missing' — no exactly-titled section; the report is downgraded.
|
||
- 'incomplete' — section present but required fields absent (listed).
|
||
- 'complete' — all base fields plus the role-specific fields present.
|
||
|
||
*role* is 'review', 'author', 'inventory', or None (base fields only).
|
||
The handoff supplements the full report; this helper never validates
|
||
the full report body, only the continuation summary.
|
||
"""
|
||
section = _handoff_section_lines(report_text)
|
||
if section is None:
|
||
return {
|
||
"verdict": "missing",
|
||
"downgraded": True,
|
||
"missing_fields": [name for name, _ in HANDOFF_BASE_FIELDS],
|
||
"reasons": [
|
||
"final report has no section titled exactly "
|
||
f"'{HANDOFF_HEADING}'"
|
||
],
|
||
}
|
||
|
||
labels = []
|
||
fields_dict = {}
|
||
for line in section:
|
||
stripped = line.strip().lstrip("-*").strip()
|
||
if ":" in stripped:
|
||
k, v = stripped.split(":", 1)
|
||
label = k.strip().lower()
|
||
labels.append(label)
|
||
fields_dict[label] = v.strip()
|
||
|
||
required = list(HANDOFF_BASE_FIELDS)
|
||
required.extend(HANDOFF_ROLE_FIELDS.get(role or "", ()))
|
||
|
||
missing = []
|
||
for name, aliases in required:
|
||
if not any(label.startswith(alias)
|
||
for label in labels for alias in aliases):
|
||
missing.append(name)
|
||
|
||
if missing:
|
||
return {
|
||
"verdict": "incomplete",
|
||
"downgraded": True,
|
||
"missing_fields": missing,
|
||
"reasons": [f"handoff missing required field: {m}"
|
||
for m in missing],
|
||
}
|
||
# Validate issue/PR references for exact number and no forbidden terms (Issue #194 / #196)
|
||
for alias in ("selected issue", "pr number opened", "pr opened", "pr number", "selected pr"):
|
||
val = fields_dict.get(alias)
|
||
if val:
|
||
numbers = re.findall(r"\d+", val)
|
||
has_forbidden = any(term in val.lower() for term in ("equivalent", "related", "same", "/"))
|
||
if len(numbers) != 1 or has_forbidden:
|
||
field_name = "Selected issue/PR"
|
||
for name, aliases in list(HANDOFF_BASE_FIELDS) + list(HANDOFF_ROLE_FIELDS.get(role or "", ())):
|
||
if alias in aliases:
|
||
field_name = name
|
||
break
|
||
return {
|
||
"verdict": "incomplete",
|
||
"downgraded": True,
|
||
"missing_fields": [field_name],
|
||
"reasons": [
|
||
f"{field_name} must specify exactly one number and no ambiguous references (got: '{val}')"
|
||
],
|
||
}
|
||
|
||
if local_edits:
|
||
workspace_mutations_val = fields_dict.get("workspace mutations", "").strip().lower()
|
||
if not workspace_mutations_val or workspace_mutations_val == "none":
|
||
return {
|
||
"verdict": "incomplete",
|
||
"downgraded": True,
|
||
"missing_fields": ["Workspace mutations"],
|
||
"reasons": [
|
||
"Workspace mutations cannot be 'none' or empty when local edits exist"
|
||
],
|
||
}
|
||
|
||
return {
|
||
"verdict": "complete",
|
||
"downgraded": False,
|
||
"missing_fields": [],
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
ROUTE_HANDOFF_FIELDS = (
|
||
("Task type", ("task type", "task_type")),
|
||
("Required role", ("required role", "required_role")),
|
||
("Active role", ("active role", "active_role")),
|
||
("Route result", ("route result", "route_result")),
|
||
)
|
||
|
||
|
||
def assess_role_route_handoff(report_text, route_result=None):
|
||
"""Issue #206: final handoff must record role routing verdict."""
|
||
text = report_text or ""
|
||
lower = text.lower()
|
||
missing = []
|
||
for name, aliases in ROUTE_HANDOFF_FIELDS:
|
||
if not any(alias in lower for alias in aliases):
|
||
missing.append(name)
|
||
if route_result is not None:
|
||
expected = str(route_result.get("route_result", "")).lower()
|
||
if expected and expected not in lower:
|
||
missing.append("route result value")
|
||
if missing:
|
||
return {
|
||
"complete": False,
|
||
"downgraded": True,
|
||
"missing_fields": missing,
|
||
"reasons": [
|
||
f"handoff missing role-route field: {field}" for field in missing
|
||
],
|
||
}
|
||
return {
|
||
"complete": True,
|
||
"downgraded": False,
|
||
"missing_fields": [],
|
||
"reasons": [],
|
||
}
|
||
|
||
|
||
def assess_mutation_namespace_handoff(
|
||
*,
|
||
declared_role: str | None = None,
|
||
namespaces_used: list[str] | None = None,
|
||
capability_profile: str | None = None,
|
||
):
|
||
"""Issue #209: handoff role and capability proof must match mutation namespace."""
|
||
reasons = []
|
||
role = (declared_role or "").strip().lower()
|
||
namespaces = list(namespaces_used or [])
|
||
|
||
reviewer_namespaces = [
|
||
ns for ns in namespaces if "reviewer" in (ns or "").lower()
|
||
]
|
||
if role == "author" and reviewer_namespaces:
|
||
reasons.append(
|
||
"author handoff reported reviewer MCP namespace for mutations")
|
||
|
||
cap = (capability_profile or "").strip().lower()
|
||
if cap and "author" in cap and reviewer_namespaces:
|
||
reasons.append(
|
||
"capability proof profile does not match mutation namespace "
|
||
f"(profile={capability_profile}, namespaces={reviewer_namespaces})")
|
||
|
||
blocked = bool(reasons)
|
||
return {
|
||
"valid": not blocked,
|
||
"blocked": blocked,
|
||
"complete": not blocked,
|
||
"downgraded": blocked,
|
||
"reasons": reasons,
|
||
"missing_fields": [] if not blocked else ["namespace parity"],
|
||
}
|
||
|
||
|
||
def assess_capability_stop_terminal_report(
|
||
report_text,
|
||
*,
|
||
trust_gate_status=None,
|
||
capability_denied=True,
|
||
):
|
||
"""Issue #197: reports after reviewer capability denial must stay pure."""
|
||
from capability_stop_terminal import assess_capability_stop_report
|
||
|
||
return assess_capability_stop_report(
|
||
report_text,
|
||
trust_gate_status=trust_gate_status,
|
||
capability_denied=capability_denied,
|
||
)
|
||
|
||
|
||
# ── PR Inventory Trust Gate (Issue #194) ──────────────────────────────────────
|
||
#
|
||
# A reviewer agent may not convert an empty PR list response into a definitive
|
||
# "no open PRs" conclusion unless the inventory result is independently proven
|
||
# trustworthy.
|
||
|
||
def pr_inventory_trust_gate(
|
||
list_prs_response: list | None,
|
||
remote: str | None = None,
|
||
org: str | None = None,
|
||
repo: str | None = None,
|
||
state: str | None = None,
|
||
authenticated_profile: dict | None = None,
|
||
local_remote_url: str | None = None,
|
||
user_context: str | None = None,
|
||
corroboration_open_pr_counter: int | None = None,
|
||
has_finality_metadata: bool = False,
|
||
) -> dict:
|
||
"""Evaluate whether an empty PR list is trusted or untrusted.
|
||
|
||
Returns a dict with 'status', 'reasons', and 'corroborated'.
|
||
"""
|
||
if list_prs_response is None or not isinstance(list_prs_response, list):
|
||
return {
|
||
"status": "inventory_error",
|
||
"reasons": ["PR list response is invalid (not a list or None)"],
|
||
"corroborated": False,
|
||
}
|
||
|
||
if len(list_prs_response) > 0:
|
||
return {
|
||
"status": "trusted_nonempty",
|
||
"reasons": [],
|
||
"corroborated": False,
|
||
}
|
||
|
||
reasons = []
|
||
|
||
# 1. Exact remote, owner, repo, and state filter resolved correctly
|
||
if not remote or remote not in ("dadeschools", "prgs"):
|
||
reasons.append("remote instance is invalid or unresolved")
|
||
if not org or not org.strip():
|
||
reasons.append("owner/org is invalid or unresolved")
|
||
if not repo or not repo.strip():
|
||
reasons.append("repository name is invalid or unresolved")
|
||
if state != "open":
|
||
reasons.append("state filter is not 'open'")
|
||
|
||
# 2. Authenticated profile permission check
|
||
if not authenticated_profile or not isinstance(authenticated_profile, dict):
|
||
reasons.append("authenticated profile is missing or invalid")
|
||
else:
|
||
allowed = authenticated_profile.get("allowed_operations") or []
|
||
if "gitea.read" not in allowed and "read" not in allowed:
|
||
reasons.append("authenticated profile lacks read permissions")
|
||
|
||
# 3. Pagination/finality metadata or independent read path corroboration
|
||
corroborated = False
|
||
if has_finality_metadata:
|
||
corroborated = True
|
||
elif corroboration_open_pr_counter == 0:
|
||
corroborated = True
|
||
else:
|
||
reasons.append("pagination finality not proven and open_pr_counter corroboration is missing or non-zero")
|
||
|
||
# 4. Local checkout remote URL matching the target repo
|
||
if not local_remote_url or not isinstance(local_remote_url, str):
|
||
reasons.append("local checkout remote URL is missing or invalid")
|
||
else:
|
||
expected = f"{org}/{repo}".lower()
|
||
if expected not in local_remote_url.lower():
|
||
reasons.append(f"local remote URL does not match target repository '{org}/{repo}'")
|
||
|
||
# 5. User context check (indicators that PRs should exist)
|
||
if user_context and isinstance(user_context, str):
|
||
indicators = ["pr #", "pull request #", "open pr", "pr queue"]
|
||
found = [ind for ind in indicators if ind in user_context.lower()]
|
||
if found:
|
||
reasons.append(f"user context indicates open PRs should exist (matched: {', '.join(found)})")
|
||
|
||
if reasons:
|
||
return {
|
||
"status": "untrusted_empty",
|
||
"reasons": reasons,
|
||
"corroborated": corroborated,
|
||
}
|
||
|
||
return {
|
||
"status": "trusted_empty",
|
||
"reasons": [],
|
||
"corroborated": corroborated,
|
||
}
|
||
|
||
|
||
def assess_duplicate_search_proof(report_text, matches):
|
||
"""#207: reject LLM duplicate summaries that omit known title matches."""
|
||
return issue_duplicate_gate.assess_duplicate_search_proof(
|
||
report_text, matches
|
||
)
|
||
|
||
|
||
def build_review_mutation_proof(run_log: list[dict]) -> dict:
|
||
"""Assess the live review mutations recorded during this run."""
|
||
mutations = [e for e in (run_log or []) if e.get("kind") == "live_review_mutation"]
|
||
if not mutations:
|
||
return {
|
||
"complete": False,
|
||
"downgraded": True,
|
||
"missing_fields": ["live review mutation"],
|
||
"reasons": ["no live review mutation recorded in run log"],
|
||
}
|
||
return {
|
||
"complete": True,
|
||
"downgraded": False,
|
||
"missing_fields": [],
|
||
"reasons": [],
|
||
}
|