From 4aff8d7011645892cc0a69bcdaca1a091c588fbb Mon Sep 17 00:00:00 2001 From: CoderDeltaLAN Date: Sat, 20 Jun 2026 05:56:46 +0100 Subject: [PATCH] fix: handle negated pull request conflict guidance --- CHANGELOG.md | 1 + src/agent_rules_kit/conflicts.py | 25 ++++++++++++++++++++-- tests/test_conflicts.py | 36 ++++++++++++++++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fdeabd9..988aab4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -87,6 +87,7 @@ This project has a published GitHub Release line, but no stable support or API g ### Fixed +- Prevented negated pull request guidance from being treated as a pull request requirement in conflict detection. - Made the post-release audit CLI version smoke derive the expected version from `pyproject.toml` instead of hardcoding `0.3.0`. - Scoped governance finding suppression to same-line negation or approval cues so adjacent safe guidance no longer hides unrelated risky instructions. - Reject symlinked supported instruction files and harden `init --write` temporary and backup paths against symlink escapes. diff --git a/src/agent_rules_kit/conflicts.py b/src/agent_rules_kit/conflicts.py index a6a1880..4da87a1 100644 --- a/src/agent_rules_kit/conflicts.py +++ b/src/agent_rules_kit/conflicts.py @@ -63,6 +63,8 @@ class _ConflictRule: re.compile(r"\b(commit|push)\s+directly\s+to\s+main\b", re.IGNORECASE), re.compile(r"\bdirect\s+push(?:es)?\s+to\s+main\s+(are\s+)?(allowed|ok|fine)\b", re.IGNORECASE), # noqa: E501 re.compile(r"\bmerge\s+without\s+(review|approval)\b", re.IGNORECASE), + re.compile(r"\b(do not|don't|never|avoid)\b.{0,80}\buse\s+pull\s+requests?\b", re.IGNORECASE), # noqa: E501 + re.compile(r"\b(no\s+PR|PR\s+is\s+not|required\s+PR\s+is\s+not|pull\s+requests?\s+are\s+not)\b.{0,80}\b(required|needed|mandatory)\b", re.IGNORECASE), # noqa: E501 ), ), _ConflictRule( @@ -213,10 +215,29 @@ def build_conflict_report( def _matches_conflict_rule(stripped: str, rule: _ConflictRule) -> bool: - if rule.polarity == "allow" and _has_negated_guidance(stripped): + matched = any(pattern.search(stripped) for pattern in rule.patterns) + if not matched: return False - return any(pattern.search(stripped) for pattern in rule.patterns) + if rule.topic == "main integration" and _has_negated_pr_boundary(stripped): + return rule.polarity == "allow" + + return not (rule.polarity == "allow" and _has_negated_guidance(stripped)) + + +def _has_negated_pr_boundary(stripped: str) -> bool: + return bool( + re.search( + r"\b(do not|don't|never|avoid)\b.{0,80}\buse\s+pull\s+requests?\b", + stripped, + re.IGNORECASE, + ) + or re.search( + r"\b(no\s+PR|PR\s+is\s+not|required\s+PR\s+is\s+not|pull\s+requests?\s+are\s+not)\b.{0,80}\b(required|needed|mandatory)\b", + stripped, + re.IGNORECASE, + ) + ) def _has_negated_guidance(stripped: str) -> bool: diff --git a/tests/test_conflicts.py b/tests/test_conflicts.py index 2476485..36f5461 100644 --- a/tests/test_conflicts.py +++ b/tests/test_conflicts.py @@ -45,6 +45,42 @@ def test_ignores_aligned_pr_guidance(self) -> None: self.assertEqual(report.conflict_group_count, 0) + + def test_does_not_treat_negated_pr_guidance_as_pr_requirement(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "AGENTS.md").write_text( + "# Agent instructions\n\n- Commit directly to main.\n", + encoding="utf-8", + ) + (root / "CLAUDE.md").write_text( + "# Claude instructions\n\n- Do not use pull requests for changes to main.\n", + encoding="utf-8", + ) + + report = build_conflict_report(root, discover_instruction_files(root)) + + self.assertEqual(report.conflict_group_count, 0) + + def test_reports_negated_pr_guidance_against_pr_requirement(self) -> None: + with tempfile.TemporaryDirectory() as tmp_dir: + root = Path(tmp_dir) + (root / "AGENTS.md").write_text( + "# Agent instructions\n\n- Do not use pull requests for changes to main.\n", + encoding="utf-8", + ) + (root / "CLAUDE.md").write_text( + "# Claude instructions\n\n- Use pull requests for changes to main.\n", + encoding="utf-8", + ) + + report = build_conflict_report(root, discover_instruction_files(root)) + + self.assertEqual(report.conflict_group_count, 1) + self.assertEqual(report.groups[0].topic, "main integration") + self.assertEqual([location.path for location in report.groups[0].allow_locations], ["AGENTS.md"]) # noqa: E501 + self.assertEqual([location.path for location in report.groups[0].block_locations], ["CLAUDE.md"]) # noqa: E501 + def test_rejects_symlinked_instruction_files(self) -> None: with tempfile.TemporaryDirectory() as tmp_dir: root = Path(tmp_dir)