Skip to content

Commit d51589e

Browse files
test(merge-guard): dropped-constraint REFUSE coverage for --match-head-commit (#1042)
Add TestMatchHeadCommitDroppedConstraint: approve-with/execute-without REFUSE, added-direction REFUSE, value-mismatch REFUSE (proves the value binds), identical AUTHORIZE, plus spaced + =-joined scanner pins. Non-vacuity measured: a source-only revert of the denylist entry reds exactly 5 of these. The approval token is scanner-derived (not hand-built) so the refusal is provably coupled to the binding.
1 parent be9cb44 commit d51589e

1 file changed

Lines changed: 65 additions & 0 deletions

File tree

pact-plugin/tests/test_merge_guard_privileged_flags.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,68 @@ def test_approved_repo_executed_bare_refuses(self):
204204
)
205205

206206

207+
class TestMatchHeadCommitDroppedConstraint:
208+
"""--match-head-commit <sha> is a value-carrying SAFETY constraint (merge only
209+
if HEAD is still <sha>) — the canonical DROPPED-CONSTRAINT case set-equality
210+
catches that a subset rule (executed ⊆ approved) would silently ALLOW. Bound
211+
value-taking on the merge op-class (R4 denylist add).
212+
213+
NON-VACUITY (essential): the approval token is SCANNER-DERIVED via
214+
extract_command_context, NOT hand-built. A hand-built
215+
{--match-head-commit=ABC123} token would REFUSE the bare execute REGARDLESS of
216+
whether --match-head-commit is in the denylist — the token/cmd asymmetry would
217+
drive the refusal, not the binding (vacuous w.r.t. the R4 config add). By
218+
deriving the token through the live scanner, a SOURCE-ONLY revert of the R4
219+
denylist add makes the scanner bind [] for --match-head-commit → the approval
220+
token's bound_flags collapse to {} → {} == {} → the dropped constraint
221+
AUTHORIZES → these REFUSE tests flip RED (the dropped-constraint, added, and
222+
different-value cases; the identical-match positive stays GREEN)."""
223+
224+
_APPROVAL = "gh pr merge 5 --match-head-commit ABC123"
225+
226+
@staticmethod
227+
def _scanner_token(approval_command: str) -> dict:
228+
"""Build the approval token by SCANNING the approval command through the
229+
production SSOT, so bound_flags reflect the LIVE denylist (couples the
230+
dropped-constraint refusal to the --match-head-commit binding)."""
231+
from shared.merge_guard_common import extract_command_context
232+
233+
return {"context": extract_command_context(approval_command)}
234+
235+
def test_dropped_constraint_refuses(self):
236+
"""Approve WITH the safety constraint, execute WITHOUT it → REFUSE — the
237+
dropped-constraint direction a subset rule would silently allow."""
238+
from merge_guard_pre import _token_matches_command
239+
240+
assert not _token_matches_command(self._scanner_token(self._APPROVAL), "gh pr merge 5")
241+
242+
def test_added_constraint_refuses(self):
243+
"""Approve bare, execute WITH --match-head-commit → REFUSE (added direction;
244+
the executed side is scanned live)."""
245+
from merge_guard_pre import _token_matches_command
246+
247+
assert not _token_matches_command(
248+
self._scanner_token("gh pr merge 5"), self._APPROVAL
249+
)
250+
251+
def test_different_constraint_value_refuses(self):
252+
"""Approve --match-head-commit ABC123, execute --match-head-commit DEF456 →
253+
REFUSE — a DIFFERENT head-sha is a different binding (the VALUE is bound,
254+
not merely the flag's presence)."""
255+
from merge_guard_pre import _token_matches_command
256+
257+
assert not _token_matches_command(
258+
self._scanner_token(self._APPROVAL), "gh pr merge 5 --match-head-commit DEF456"
259+
)
260+
261+
def test_identical_constraint_authorizes(self):
262+
"""Approve and execute with the SAME constraint → AUTHORIZE — a faithful
263+
re-execution is not over-blocked."""
264+
from merge_guard_pre import _token_matches_command
265+
266+
assert _token_matches_command(self._scanner_token(self._APPROVAL), self._APPROVAL)
267+
268+
207269
# ════════════════════════════════════════════════════════════════════════════
208270
# READ ARM — bounded over-block: an EXACT (form-invariant) match AUTHORIZES
209271
# ════════════════════════════════════════════════════════════════════════════
@@ -305,6 +367,9 @@ class TestScannerCanonicalForms:
305367
# cluster with an UNBOUND short (-s ignored), bound -d kept
306368
("gh pr merge 5 -sd", "merge", ["--delete-branch"]),
307369
("gh pr merge 5 -d", "merge", ["--delete-branch"]),
370+
# --match-head-commit (value-taking SAFETY constraint, R4): spaced + =-joined
371+
("gh pr merge 5 --match-head-commit ABC123", "merge", ["--match-head-commit=ABC123"]),
372+
("gh pr merge 5 --match-head-commit=ABC123", "merge", ["--match-head-commit=ABC123"]),
308373
# benign: no bound flag → empty set
309374
("gh pr merge 5", "merge", []),
310375
# close op-class: -R bound; --delete-branch is the op-trigger (NOT bound)

0 commit comments

Comments
 (0)