Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 23 additions & 2 deletions src/agent_rules_kit/conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
36 changes: 36 additions & 0 deletions tests/test_conflicts.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down