Skip to content

Commit bec69dd

Browse files
feat: add conflicts baseline command (#109)
Adds a read-only conflicts baseline command for deterministic contradictory-guidance detection across supported instruction files.
1 parent f3ddfda commit bec69dd

4 files changed

Lines changed: 419 additions & 0 deletions

File tree

src/agent_rules_kit/cli.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
from agent_rules_kit import __version__
1212
from agent_rules_kit.budget import BudgetReport, build_budget_report
13+
from agent_rules_kit.conflicts import ConflictReport, build_conflict_report
1314
from agent_rules_kit.dedupe import DedupeReport, build_dedupe_report
1415
from agent_rules_kit.discovery import InstructionFile, discover_instruction_files
1516
from agent_rules_kit.explain import (
@@ -111,6 +112,17 @@ def build_parser() -> argparse.ArgumentParser:
111112
help="Repository root to inspect. Defaults to the current directory.",
112113
)
113114

115+
conflicts_parser = subparsers.add_parser(
116+
"conflicts",
117+
help="Detect contradictory guidance across supported instruction files.",
118+
)
119+
conflicts_parser.add_argument(
120+
"repository",
121+
nargs="?",
122+
default=".",
123+
help="Repository root to inspect. Defaults to the current directory.",
124+
)
125+
114126
explain_parser = subparsers.add_parser(
115127
"explain",
116128
help="Explain known governance rule IDs.",
@@ -158,6 +170,9 @@ def main(argv: Sequence[str] | None = None) -> int:
158170
if args.command == "dedupe":
159171
return _run_dedupe(Path(args.repository))
160172

173+
if args.command == "conflicts":
174+
return _run_conflicts(Path(args.repository))
175+
161176
if args.command == "explain":
162177
return _run_explain(args.rule_id, list_rules=args.list_rules)
163178

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

210225

211226

227+
228+
def _run_conflicts(repository_root: Path) -> int:
229+
try:
230+
instruction_files = discover_instruction_files(repository_root)
231+
report = build_conflict_report(repository_root, instruction_files)
232+
except ValueError as error:
233+
print(f"ERROR: {redact_secret_like_values(str(error))}", file=sys.stderr)
234+
return 2
235+
236+
return _print_console_conflicts(repository_root, instruction_files, report)
237+
238+
239+
def _print_console_conflicts(
240+
repository_root: Path,
241+
instruction_files: tuple[InstructionFile, ...],
242+
report: ConflictReport,
243+
) -> int:
244+
print(f"agent-rules-kit conflicts: {redact_secret_like_values(str(repository_root))}")
245+
246+
if not instruction_files:
247+
print("Status: no_instruction_files")
248+
print("Supported instruction files: 0")
249+
print("Conflict groups: 0")
250+
print("Conflict lines: 0")
251+
print("Next step: add a supported agent instruction file before checking conflicts.")
252+
return 1
253+
254+
status = "review" if report.groups else "ok"
255+
print(f"Status: {status}")
256+
print(f"Supported instruction files: {len(instruction_files)}")
257+
print(f"Conflict groups: {report.conflict_group_count}")
258+
print(f"Conflict lines: {report.conflict_line_count}")
259+
260+
if report.groups:
261+
print("Conflict groups:")
262+
for index, group in enumerate(report.groups, start=1):
263+
print(f"{index}. {group.topic}: {group.summary}")
264+
print(" Allowing guidance:")
265+
for location in group.allow_locations:
266+
path = redact_secret_like_values(location.path)
267+
evidence = redact_secret_like_values(location.evidence)
268+
print(f" - {path}:{location.line} {evidence}")
269+
print(" Blocking guidance:")
270+
for location in group.block_locations:
271+
path = redact_secret_like_values(location.path)
272+
evidence = redact_secret_like_values(location.evidence)
273+
print(f" - {path}:{location.line} {evidence}")
274+
print("Next step: choose one source of truth and remove or narrow the opposing guidance.")
275+
else:
276+
print("Next step: no contradictory guidance was detected by implemented checks.")
277+
278+
return 0
279+
212280
def _run_dedupe(repository_root: Path) -> int:
213281
try:
214282
instruction_files = discover_instruction_files(repository_root)

src/agent_rules_kit/conflicts.py

Lines changed: 250 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,250 @@
1+
"""Deterministic conflicting instruction detection."""
2+
3+
from __future__ import annotations
4+
5+
import re
6+
from dataclasses import dataclass
7+
from pathlib import Path
8+
9+
from agent_rules_kit.discovery import InstructionFile
10+
11+
12+
@dataclass(frozen=True, slots=True)
13+
class ConflictLocation:
14+
"""One occurrence of guidance participating in a conflict."""
15+
16+
path: str
17+
line: int
18+
evidence: str
19+
20+
21+
@dataclass(frozen=True, slots=True)
22+
class ConflictGroup:
23+
"""Opposing guidance detected for one instruction topic."""
24+
25+
topic: str
26+
summary: str
27+
allow_locations: tuple[ConflictLocation, ...]
28+
block_locations: tuple[ConflictLocation, ...]
29+
30+
31+
@dataclass(frozen=True, slots=True)
32+
class ConflictReport:
33+
"""Conflict report for supported instruction files."""
34+
35+
groups: tuple[ConflictGroup, ...]
36+
37+
@property
38+
def conflict_group_count(self) -> int:
39+
return len(self.groups)
40+
41+
@property
42+
def conflict_line_count(self) -> int:
43+
return sum(
44+
len(group.allow_locations) + len(group.block_locations)
45+
for group in self.groups
46+
)
47+
48+
49+
@dataclass(frozen=True, slots=True)
50+
class _ConflictRule:
51+
topic: str
52+
polarity: str
53+
summary: str
54+
patterns: tuple[re.Pattern[str], ...]
55+
56+
57+
_CONFLICT_RULES: tuple[_ConflictRule, ...] = (
58+
_ConflictRule(
59+
topic="main integration",
60+
polarity="allow",
61+
summary="direct-main guidance conflicts with PR/review boundaries",
62+
patterns=(
63+
re.compile(r"\b(commit|push)\s+directly\s+to\s+main\b", re.IGNORECASE),
64+
re.compile(r"\bdirect\s+push(?:es)?\s+to\s+main\s+(are\s+)?(allowed|ok|fine)\b", re.IGNORECASE), # noqa: E501
65+
re.compile(r"\bmerge\s+without\s+(review|approval)\b", re.IGNORECASE),
66+
),
67+
),
68+
_ConflictRule(
69+
topic="main integration",
70+
polarity="block",
71+
summary="direct-main guidance conflicts with PR/review boundaries",
72+
patterns=(
73+
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
74+
re.compile(r"\buse\s+pull\s+requests?\b", re.IGNORECASE),
75+
re.compile(r"\bPR\s+(is\s+)?required\b", re.IGNORECASE),
76+
),
77+
),
78+
_ConflictRule(
79+
topic="checks",
80+
polarity="allow",
81+
summary="skip-check guidance conflicts with mandatory validation guidance",
82+
patterns=(
83+
re.compile(r"\b(ignore|skip)\s+(failing\s+)?(checks|tests|ci)\b", re.IGNORECASE),
84+
re.compile(r"\btests?\s+can\s+be\s+skipped\b", re.IGNORECASE),
85+
re.compile(r"\bCI\s+can\s+be\s+ignored\b", re.IGNORECASE),
86+
),
87+
),
88+
_ConflictRule(
89+
topic="checks",
90+
polarity="block",
91+
summary="skip-check guidance conflicts with mandatory validation guidance",
92+
patterns=(
93+
re.compile(r"\b(run|execute)\b.{0,80}\b(checks|tests|ci)\b", re.IGNORECASE),
94+
re.compile(r"\b(checks|tests|ci)\b.{0,80}\b(must|required|before\s+(commit|push|merge))\b", re.IGNORECASE), # noqa: E501
95+
re.compile(r"\bdo\s+not\s+(ignore|skip)\s+(checks|tests|ci)\b", re.IGNORECASE),
96+
),
97+
),
98+
_ConflictRule(
99+
topic="runtime network or LLM",
100+
polarity="allow",
101+
summary="runtime network or LLM guidance conflicts with local-first boundaries",
102+
patterns=(
103+
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
104+
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
105+
),
106+
),
107+
_ConflictRule(
108+
topic="runtime network or LLM",
109+
polarity="block",
110+
summary="runtime network or LLM guidance conflicts with local-first boundaries",
111+
patterns=(
112+
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
113+
re.compile(r"\b(local-first|local first|read-only local)\b", re.IGNORECASE),
114+
),
115+
),
116+
_ConflictRule(
117+
topic="secrets",
118+
polarity="allow",
119+
summary="secret-handling guidance conflicts with no-secret boundaries",
120+
patterns=(
121+
re.compile(r"\b(commit|store|check in|include)\b.{0,80}\b(secrets?|tokens?|credentials?|api[-_ ]?keys?)\b", re.IGNORECASE), # noqa: E501
122+
re.compile(r"\bsecrets?\b.{0,80}\b(allowed|ok|fine)\b", re.IGNORECASE),
123+
),
124+
),
125+
_ConflictRule(
126+
topic="secrets",
127+
polarity="block",
128+
summary="secret-handling guidance conflicts with no-secret boundaries",
129+
patterns=(
130+
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
131+
re.compile(r"\bno\s+secrets?\b", re.IGNORECASE),
132+
),
133+
),
134+
_ConflictRule(
135+
topic="unsafe commands",
136+
polarity="allow",
137+
summary="automatic unsafe-command guidance conflicts with confirmation boundaries",
138+
patterns=(
139+
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
140+
re.compile(r"\buse\s+sudo\b.{0,80}\b(default|normal|routine|always)\b", re.IGNORECASE),
141+
re.compile(r"\brun\b.{0,80}\brepository\s+scripts?\b.{0,80}\b(automatically|without asking)\b", re.IGNORECASE), # noqa: E501
142+
),
143+
),
144+
_ConflictRule(
145+
topic="unsafe commands",
146+
polarity="block",
147+
summary="automatic unsafe-command guidance conflicts with confirmation boundaries",
148+
patterns=(
149+
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
150+
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
151+
re.compile(r"\b(explicit|human|maintainer|user)\b.{0,80}\b(approval|confirmation|permission)\b", re.IGNORECASE), # noqa: E501
152+
),
153+
),
154+
)
155+
156+
157+
def build_conflict_report(
158+
repository_root: Path,
159+
instruction_files: tuple[InstructionFile, ...],
160+
) -> ConflictReport:
161+
"""Build a conservative report of opposite instruction guidance."""
162+
matches: dict[str, dict[str, list[ConflictLocation]]] = {
163+
rule.topic: {"allow": [], "block": []} for rule in _CONFLICT_RULES
164+
}
165+
summaries = {rule.topic: rule.summary for rule in _CONFLICT_RULES}
166+
167+
for instruction_file in instruction_files:
168+
file_path = repository_root / instruction_file.path
169+
170+
if file_path.is_symlink():
171+
raise ValueError(
172+
"instruction file path is a symlink and cannot be checked for conflicts: "
173+
f"{instruction_file.path}"
174+
)
175+
176+
try:
177+
text = file_path.read_text(encoding="utf-8")
178+
except UnicodeDecodeError as error:
179+
raise ValueError(
180+
"instruction file is not valid UTF-8 and cannot be checked for conflicts: "
181+
f"{instruction_file.path}"
182+
) from error
183+
184+
for line_number, line_text in enumerate(text.splitlines(), start=1):
185+
stripped = line_text.strip()
186+
if not _is_scannable_instruction_line(stripped):
187+
continue
188+
189+
for rule in _CONFLICT_RULES:
190+
if not _matches_conflict_rule(stripped, rule):
191+
continue
192+
193+
matches[rule.topic][rule.polarity].append(
194+
ConflictLocation(
195+
path=instruction_file.path,
196+
line=line_number,
197+
evidence=stripped,
198+
)
199+
)
200+
201+
groups = [
202+
ConflictGroup(
203+
topic=topic,
204+
summary=summaries[topic],
205+
allow_locations=tuple(polarities["allow"]),
206+
block_locations=tuple(polarities["block"]),
207+
)
208+
for topic, polarities in matches.items()
209+
if polarities["allow"] and polarities["block"]
210+
]
211+
212+
return ConflictReport(groups=tuple(groups))
213+
214+
215+
def _matches_conflict_rule(stripped: str, rule: _ConflictRule) -> bool:
216+
if rule.polarity == "allow" and _has_negated_guidance(stripped):
217+
return False
218+
219+
return any(pattern.search(stripped) for pattern in rule.patterns)
220+
221+
222+
def _has_negated_guidance(stripped: str) -> bool:
223+
return bool(
224+
re.search(
225+
r"\b(do not|don't|never|avoid|no|must not|should not)\b",
226+
stripped,
227+
re.IGNORECASE,
228+
)
229+
)
230+
231+
232+
def _is_scannable_instruction_line(stripped: str) -> bool:
233+
if not stripped:
234+
return False
235+
236+
if stripped.startswith(("```", "---", "<!--")):
237+
return False
238+
239+
if len(stripped) < 12:
240+
return False
241+
242+
return any(character.isalpha() for character in stripped)
243+
244+
245+
__all__ = [
246+
"ConflictGroup",
247+
"ConflictLocation",
248+
"ConflictReport",
249+
"build_conflict_report",
250+
]

tests/test_cli.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,47 @@ def test_budget_returns_two_for_invalid_repository_root(self) -> None:
195195
self.assertIn("ERROR: repository root does not exist:", output.getvalue())
196196

197197

198+
199+
def test_conflicts_reports_opposing_guidance(self) -> None:
200+
with tempfile.TemporaryDirectory() as tmp_dir:
201+
root = Path(tmp_dir)
202+
(root / "AGENTS.md").write_text(
203+
"# Agent instructions\n\n- Commit directly to main.\n",
204+
encoding="utf-8",
205+
)
206+
(root / "CLAUDE.md").write_text(
207+
"# Claude instructions\n\n- Use pull requests for changes to main.\n",
208+
encoding="utf-8",
209+
)
210+
211+
output = io.StringIO()
212+
213+
with redirect_stdout(output):
214+
exit_code = main(["conflicts", str(root)])
215+
216+
text = output.getvalue()
217+
218+
self.assertEqual(exit_code, 0)
219+
self.assertIn("agent-rules-kit conflicts:", text)
220+
self.assertIn("Status: review", text)
221+
self.assertIn("Conflict groups: 1", text)
222+
self.assertIn("Conflict lines: 2", text)
223+
self.assertIn("main integration", text)
224+
self.assertIn("AGENTS.md:3", text)
225+
self.assertIn("CLAUDE.md:3", text)
226+
227+
def test_conflicts_returns_one_when_no_instruction_files_are_found(self) -> None:
228+
output = io.StringIO()
229+
230+
with redirect_stdout(output):
231+
exit_code = main(["conflicts", str(FIXTURE_ROOT / "empty-repo")])
232+
233+
text = output.getvalue()
234+
235+
self.assertEqual(exit_code, 1)
236+
self.assertIn("Status: no_instruction_files", text)
237+
self.assertIn("Conflict groups: 0", text)
238+
198239
def test_dedupe_reports_duplicate_lines(self) -> None:
199240
with tempfile.TemporaryDirectory() as tmp_dir:
200241
root = Path(tmp_dir)

0 commit comments

Comments
 (0)