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
68 changes: 68 additions & 0 deletions src/agent_rules_kit/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

from agent_rules_kit import __version__
from agent_rules_kit.budget import BudgetReport, build_budget_report
from agent_rules_kit.conflicts import ConflictReport, build_conflict_report
from agent_rules_kit.dedupe import DedupeReport, build_dedupe_report
from agent_rules_kit.discovery import InstructionFile, discover_instruction_files
from agent_rules_kit.explain import (
Expand Down Expand Up @@ -111,6 +112,17 @@ def build_parser() -> argparse.ArgumentParser:
help="Repository root to inspect. Defaults to the current directory.",
)

conflicts_parser = subparsers.add_parser(
"conflicts",
help="Detect contradictory guidance across supported instruction files.",
)
conflicts_parser.add_argument(
"repository",
nargs="?",
default=".",
help="Repository root to inspect. Defaults to the current directory.",
)

explain_parser = subparsers.add_parser(
"explain",
help="Explain known governance rule IDs.",
Expand Down Expand Up @@ -158,6 +170,9 @@ def main(argv: Sequence[str] | None = None) -> int:
if args.command == "dedupe":
return _run_dedupe(Path(args.repository))

if args.command == "conflicts":
return _run_conflicts(Path(args.repository))

if args.command == "explain":
return _run_explain(args.rule_id, list_rules=args.list_rules)

Expand Down Expand Up @@ -209,6 +224,59 @@ def _print_rule_explanation(explanation: RuleExplanation) -> None:




def _run_conflicts(repository_root: Path) -> int:
try:
instruction_files = discover_instruction_files(repository_root)
report = build_conflict_report(repository_root, instruction_files)
except ValueError as error:
print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr)
return 2

return _print_console_conflicts(repository_root, instruction_files, report)


def _print_console_conflicts(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
report: ConflictReport,
) -> int:
print(f"agent-rules-kit conflicts: {redact_secret_like_values(str(repository_root))}")

if not instruction_files:
print("Status: no_instruction_files")
print("Supported instruction files: 0")
print("Conflict groups: 0")
print("Conflict lines: 0")
print("Next step: add a supported agent instruction file before checking conflicts.")
return 1

status = "review" if report.groups else "ok"
print(f"Status: {status}")
print(f"Supported instruction files: {len(instruction_files)}")
print(f"Conflict groups: {report.conflict_group_count}")
print(f"Conflict lines: {report.conflict_line_count}")

if report.groups:
print("Conflict groups:")
for index, group in enumerate(report.groups, start=1):
print(f"{index}. {group.topic}: {group.summary}")
print(" Allowing guidance:")
for location in group.allow_locations:
path = redact_secret_like_values(location.path)
evidence = redact_secret_like_values(location.evidence)
print(f" - {path}:{location.line} {evidence}")
print(" Blocking guidance:")
for location in group.block_locations:
path = redact_secret_like_values(location.path)
evidence = redact_secret_like_values(location.evidence)
print(f" - {path}:{location.line} {evidence}")
print("Next step: choose one source of truth and remove or narrow the opposing guidance.")
else:
print("Next step: no contradictory guidance was detected by implemented checks.")

return 0

def _run_dedupe(repository_root: Path) -> int:
try:
instruction_files = discover_instruction_files(repository_root)
Expand Down
250 changes: 250 additions & 0 deletions src/agent_rules_kit/conflicts.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
"""Deterministic conflicting instruction detection."""

from __future__ import annotations

import re
from dataclasses import dataclass
from pathlib import Path

from agent_rules_kit.discovery import InstructionFile


@dataclass(frozen=True, slots=True)
class ConflictLocation:
"""One occurrence of guidance participating in a conflict."""

path: str
line: int
evidence: str


@dataclass(frozen=True, slots=True)
class ConflictGroup:
"""Opposing guidance detected for one instruction topic."""

topic: str
summary: str
allow_locations: tuple[ConflictLocation, ...]
block_locations: tuple[ConflictLocation, ...]


@dataclass(frozen=True, slots=True)
class ConflictReport:
"""Conflict report for supported instruction files."""

groups: tuple[ConflictGroup, ...]

@property
def conflict_group_count(self) -> int:
return len(self.groups)

@property
def conflict_line_count(self) -> int:
return sum(
len(group.allow_locations) + len(group.block_locations)
for group in self.groups
)


@dataclass(frozen=True, slots=True)
class _ConflictRule:
topic: str
polarity: str
summary: str
patterns: tuple[re.Pattern[str], ...]


_CONFLICT_RULES: tuple[_ConflictRule, ...] = (
_ConflictRule(
topic="main integration",
polarity="allow",
summary="direct-main guidance conflicts with PR/review boundaries",
patterns=(
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),
),
),
_ConflictRule(
topic="main integration",
polarity="block",
summary="direct-main guidance conflicts with PR/review boundaries",
patterns=(
re.compile(r"\b(do not|don't|never|avoid|no)\b.{0,80}\b(commit|push|merge)\b.{0,80}\bmain\b", re.IGNORECASE), # noqa: E501
re.compile(r"\buse\s+pull\s+requests?\b", re.IGNORECASE),
re.compile(r"\bPR\s+(is\s+)?required\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="checks",
polarity="allow",
summary="skip-check guidance conflicts with mandatory validation guidance",
patterns=(
re.compile(r"\b(ignore|skip)\s+(failing\s+)?(checks|tests|ci)\b", re.IGNORECASE),
re.compile(r"\btests?\s+can\s+be\s+skipped\b", re.IGNORECASE),
re.compile(r"\bCI\s+can\s+be\s+ignored\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="checks",
polarity="block",
summary="skip-check guidance conflicts with mandatory validation guidance",
patterns=(
re.compile(r"\b(run|execute)\b.{0,80}\b(checks|tests|ci)\b", re.IGNORECASE),
re.compile(r"\b(checks|tests|ci)\b.{0,80}\b(must|required|before\s+(commit|push|merge))\b", re.IGNORECASE), # noqa: E501
re.compile(r"\bdo\s+not\s+(ignore|skip)\s+(checks|tests|ci)\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="runtime network or LLM",
polarity="allow",
summary="runtime network or LLM guidance conflicts with local-first boundaries",
patterns=(
re.compile(r"\b(use|call|query|invoke)\b.{0,80}\b(OpenAI|Anthropic|Claude|Gemini|ChatGPT|LLM|external API|remote API)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\b(runtime|check|scan|audit|validate)\b.{0,80}\b(requires?|needs?|depends on|must use)\b.{0,80}\b(network|internet|LLM|external API)\b", re.IGNORECASE), # noqa: E501
),
),
_ConflictRule(
topic="runtime network or LLM",
polarity="block",
summary="runtime network or LLM guidance conflicts with local-first boundaries",
patterns=(
re.compile(r"\b(no|without|do not|don't|never|avoid)\b.{0,100}\b(network|internet|LLM|OpenAI|Anthropic|Claude|Gemini|ChatGPT|external API|remote API)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\b(local-first|local first|read-only local)\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="secrets",
polarity="allow",
summary="secret-handling guidance conflicts with no-secret boundaries",
patterns=(
re.compile(r"\b(commit|store|check in|include)\b.{0,80}\b(secrets?|tokens?|credentials?|api[-_ ]?keys?)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\bsecrets?\b.{0,80}\b(allowed|ok|fine)\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="secrets",
polarity="block",
summary="secret-handling guidance conflicts with no-secret boundaries",
patterns=(
re.compile(r"\b(do not|don't|never|avoid|no)\b.{0,100}\b(commit|store|check in|include)\b.{0,100}\b(secrets?|tokens?|credentials?|api[-_ ]?keys?)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\bno\s+secrets?\b", re.IGNORECASE),
),
),
_ConflictRule(
topic="unsafe commands",
polarity="allow",
summary="automatic unsafe-command guidance conflicts with confirmation boundaries",
patterns=(
re.compile(r"\brun\b.{0,80}\brm\s+-[A-Za-z]*r[A-Za-z]*f\b.{0,80}\b(without asking|automatically|always)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\buse\s+sudo\b.{0,80}\b(default|normal|routine|always)\b", re.IGNORECASE),
re.compile(r"\brun\b.{0,80}\brepository\s+scripts?\b.{0,80}\b(automatically|without asking)\b", re.IGNORECASE), # noqa: E501
),
),
_ConflictRule(
topic="unsafe commands",
polarity="block",
summary="automatic unsafe-command guidance conflicts with confirmation boundaries",
patterns=(
re.compile(r"\b(do not|don't|never|avoid)\b.{0,100}\b(rm\s+-[A-Za-z]*r[A-Za-z]*f|sudo|repository\s+scripts?)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\bask\b.{0,80}\bbefore\b.{0,100}\b(rm\s+-[A-Za-z]*r[A-Za-z]*f|sudo|repository\s+scripts?)\b", re.IGNORECASE), # noqa: E501
re.compile(r"\b(explicit|human|maintainer|user)\b.{0,80}\b(approval|confirmation|permission)\b", re.IGNORECASE), # noqa: E501
),
),
)


def build_conflict_report(
repository_root: Path,
instruction_files: tuple[InstructionFile, ...],
) -> ConflictReport:
"""Build a conservative report of opposite instruction guidance."""
matches: dict[str, dict[str, list[ConflictLocation]]] = {
rule.topic: {"allow": [], "block": []} for rule in _CONFLICT_RULES
}
summaries = {rule.topic: rule.summary for rule in _CONFLICT_RULES}

for instruction_file in instruction_files:
file_path = repository_root / instruction_file.path

if file_path.is_symlink():
raise ValueError(
"instruction file path is a symlink and cannot be checked for conflicts: "
f"{instruction_file.path}"
)

try:
text = file_path.read_text(encoding="utf-8")
except UnicodeDecodeError as error:
raise ValueError(
"instruction file is not valid UTF-8 and cannot be checked for conflicts: "
f"{instruction_file.path}"
) from error

for line_number, line_text in enumerate(text.splitlines(), start=1):
stripped = line_text.strip()
if not _is_scannable_instruction_line(stripped):
continue

for rule in _CONFLICT_RULES:
if not _matches_conflict_rule(stripped, rule):
continue

matches[rule.topic][rule.polarity].append(
ConflictLocation(
path=instruction_file.path,
line=line_number,
evidence=stripped,
)
)

groups = [
ConflictGroup(
topic=topic,
summary=summaries[topic],
allow_locations=tuple(polarities["allow"]),
block_locations=tuple(polarities["block"]),
)
for topic, polarities in matches.items()
if polarities["allow"] and polarities["block"]
]

return ConflictReport(groups=tuple(groups))


def _matches_conflict_rule(stripped: str, rule: _ConflictRule) -> bool:
if rule.polarity == "allow" and _has_negated_guidance(stripped):
return False

return any(pattern.search(stripped) for pattern in rule.patterns)


def _has_negated_guidance(stripped: str) -> bool:
return bool(
re.search(
r"\b(do not|don't|never|avoid|no|must not|should not)\b",
stripped,
re.IGNORECASE,
)
)


def _is_scannable_instruction_line(stripped: str) -> bool:
if not stripped:
return False

if stripped.startswith(("```", "---", "<!--")):
return False

if len(stripped) < 12:
return False

return any(character.isalpha() for character in stripped)


__all__ = [
"ConflictGroup",
"ConflictLocation",
"ConflictReport",
"build_conflict_report",
]
41 changes: 41 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,47 @@ def test_budget_returns_two_for_invalid_repository_root(self) -> None:
self.assertIn("ERROR: repository root does not exist:", output.getvalue())



def test_conflicts_reports_opposing_guidance(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- Use pull requests for changes to main.\n",
encoding="utf-8",
)

output = io.StringIO()

with redirect_stdout(output):
exit_code = main(["conflicts", str(root)])

text = output.getvalue()

self.assertEqual(exit_code, 0)
self.assertIn("agent-rules-kit conflicts:", text)
self.assertIn("Status: review", text)
self.assertIn("Conflict groups: 1", text)
self.assertIn("Conflict lines: 2", text)
self.assertIn("main integration", text)
self.assertIn("AGENTS.md:3", text)
self.assertIn("CLAUDE.md:3", text)

def test_conflicts_returns_one_when_no_instruction_files_are_found(self) -> None:
output = io.StringIO()

with redirect_stdout(output):
exit_code = main(["conflicts", str(FIXTURE_ROOT / "empty-repo")])

text = output.getvalue()

self.assertEqual(exit_code, 1)
self.assertIn("Status: no_instruction_files", text)
self.assertIn("Conflict groups: 0", text)

def test_dedupe_reports_duplicate_lines(self) -> None:
with tempfile.TemporaryDirectory() as tmp_dir:
root = Path(tmp_dir)
Expand Down
Loading