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
76 changes: 76 additions & 0 deletions src/agent_rules_kit/findings.py
Original file line number Diff line number Diff line change
@@ -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"]
101 changes: 101 additions & 0 deletions tests/test_findings.py
Original file line number Diff line number Diff line change
@@ -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()
Loading