From 791ba1c9308052b5683fea39f37cc7e117bd623b Mon Sep 17 00:00:00 2001 From: CoderDeltaLAN Date: Tue, 9 Jun 2026 05:02:58 +0100 Subject: [PATCH] feat: add finding model --- src/agent_rules_kit/findings.py | 76 ++++++++++++++++++++++++ tests/test_findings.py | 101 ++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 src/agent_rules_kit/findings.py create mode 100644 tests/test_findings.py diff --git a/src/agent_rules_kit/findings.py b/src/agent_rules_kit/findings.py new file mode 100644 index 0000000..029a930 --- /dev/null +++ b/src/agent_rules_kit/findings.py @@ -0,0 +1,76 @@ +"""Finding model for diagnostic results.""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import StrEnum + + +class Severity(StrEnum): + """Diagnostic finding severity.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@dataclass(frozen=True, slots=True) +class Finding: + """A single diagnostic finding. + + The model is intentionally small and dependency-free so it can support + console, JSON, and Markdown output later without pulling in runtime tools. + """ + + rule_id: str + severity: Severity + message: str + path: str | None = None + line: int | None = None + column: int | None = None + + def __post_init__(self) -> None: + if not isinstance(self.severity, Severity): + raise TypeError("severity must be a Severity value") + + normalized_rule_id = self.rule_id.strip() + normalized_message = self.message.strip() + + if not normalized_rule_id: + raise ValueError("rule_id must not be blank") + if not normalized_message: + raise ValueError("message must not be blank") + + object.__setattr__(self, "rule_id", normalized_rule_id) + object.__setattr__(self, "message", normalized_message) + + if self.path is not None: + normalized_path = self.path.strip() + if not normalized_path: + raise ValueError("path must not be blank when provided") + object.__setattr__(self, "path", normalized_path) + + if self.line is not None and self.line < 1: + raise ValueError("line must be greater than or equal to 1") + if self.column is not None and self.column < 1: + raise ValueError("column must be greater than or equal to 1") + + def to_dict(self) -> dict[str, str | int]: + """Return a stable dictionary representation for future reporters.""" + data: dict[str, str | int] = { + "rule_id": self.rule_id, + "severity": self.severity.value, + "message": self.message, + } + + if self.path is not None: + data["path"] = self.path + if self.line is not None: + data["line"] = self.line + if self.column is not None: + data["column"] = self.column + + return data + + +__all__ = ["Finding", "Severity"] diff --git a/tests/test_findings.py b/tests/test_findings.py new file mode 100644 index 0000000..c5aecaa --- /dev/null +++ b/tests/test_findings.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import unittest + +from agent_rules_kit.findings import Finding, Severity + + +class FindingModelTests(unittest.TestCase): + def test_finding_serializes_minimal_fields(self) -> None: + finding = Finding( + rule_id="missing-agents-file", + severity=Severity.WARNING, + message="AGENTS.md was not found.", + ) + + self.assertEqual( + finding.to_dict(), + { + "rule_id": "missing-agents-file", + "severity": "warning", + "message": "AGENTS.md was not found.", + }, + ) + + def test_finding_serializes_location_fields_when_present(self) -> None: + finding = Finding( + rule_id="unsafe-instruction", + severity=Severity.ERROR, + message="Instruction asks to ignore failing checks.", + path="AGENTS.md", + line=7, + column=3, + ) + + self.assertEqual( + finding.to_dict(), + { + "rule_id": "unsafe-instruction", + "severity": "error", + "message": "Instruction asks to ignore failing checks.", + "path": "AGENTS.md", + "line": 7, + "column": 3, + }, + ) + + def test_finding_normalizes_surrounding_whitespace(self) -> None: + finding = Finding( + rule_id=" duplicate-guidance ", + severity=Severity.INFO, + message=" Similar instructions appear in multiple files. ", + path=" CLAUDE.md ", + ) + + self.assertEqual(finding.rule_id, "duplicate-guidance") + self.assertEqual(finding.message, "Similar instructions appear in multiple files.") + self.assertEqual(finding.path, "CLAUDE.md") + + def test_finding_rejects_blank_required_fields(self) -> None: + with self.assertRaises(ValueError): + Finding(rule_id="", severity=Severity.INFO, message="Message.") + + with self.assertRaises(ValueError): + Finding(rule_id="rule-id", severity=Severity.INFO, message=" ") + + def test_finding_rejects_invalid_location_values(self) -> None: + with self.assertRaises(ValueError): + Finding( + rule_id="rule-id", + severity=Severity.INFO, + message="Message.", + path=" ", + ) + + with self.assertRaises(ValueError): + Finding( + rule_id="rule-id", + severity=Severity.INFO, + message="Message.", + line=0, + ) + + with self.assertRaises(ValueError): + Finding( + rule_id="rule-id", + severity=Severity.INFO, + message="Message.", + column=0, + ) + + def test_finding_rejects_non_severity_values(self) -> None: + with self.assertRaises(TypeError): + Finding( + rule_id="rule-id", + severity="warning", # type: ignore[arg-type] + message="Message.", + ) + + +if __name__ == "__main__": + unittest.main()