Skip to content
Open
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
134 changes: 134 additions & 0 deletions .forgeproof/chain-9.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
[
{
"index": 0,
"timestamp": "2026-07-06T04:27:28.973771+00:00",
"action": "genesis",
"data": {
"issue": 9,
"title": "Feature: Add priority levels to Task",
"requirements": [
"REQ-1: Each Task has a priority attribute with allowed values low, medium, high; default is medium",
"REQ-2: Creating or setting a Task with any other priority value raises ValueError",
"REQ-3: TaskStore has a filter_by_priority(priority) method returning tasks matching the given priority",
"REQ-4: Existing behavior (creation, completion, serialization) is unchanged for tasks that never set a priority"
]
},
"prev_hash": "0000000000000000000000000000000000000000000000000000000000000000",
"hash": "e4cb589ffdca29c85af325deefd38d69ea26d965023ba494bc21ee1b7e457ba0",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEBJhK2GYt67kphuUEqfkiqVT+rOA0OqH15Sn/WH/P0qV7R8IpfOZuQWMB\njcP3av5Oz/74lpMmPp1BKuB1CltTAN\n-----END SSH SIGNATURE-----"
},
{
"index": 1,
"timestamp": "2026-07-06T04:27:54.408673+00:00",
"action": "branch-create",
"data": {
"branch": "forgeproof/9",
"base": "main",
"base_sha": "97d0e0888111b9c36921daf76fe004bad6ca67e3"
},
"prev_hash": "e4cb589ffdca29c85af325deefd38d69ea26d965023ba494bc21ee1b7e457ba0",
"hash": "4f4843185a04c1020e3b8f57f4fe2b2875de3d4d074c4c0eeebf9ad14d5e70cc",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAECZkHyaCO7MhZ5Po9UfWfbtaRp2UzVcptOyU6Uh1tYCuXItON6jHJfqox\ncXDSYXQG4eGGbbLH2BEkf/2MJTZ34L\n-----END SSH SIGNATURE-----"
},
{
"index": 2,
"timestamp": "2026-07-06T04:28:14.179356+00:00",
"action": "file-edit",
"data": {
"path": "src/task.py",
"operation": "modify",
"sha256": "564372076d3e12bab4f2ee3b98c55e35c5d0347e51a5dd83f941b14c82a188d8"
},
"prev_hash": "4f4843185a04c1020e3b8f57f4fe2b2875de3d4d074c4c0eeebf9ad14d5e70cc",
"hash": "beb25eb72c25fb4ee8f4ae9190239d15fd4fb0183538a89dfe6c5311ee04c071",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEBfEuRRbQWNxfvLmWPQi26VVkWiQL2Td1BykARKKQVQDlgolrpv/f7Ydi\nytFhQGsphOCutp4myxqr8iQJj4Xm8L\n-----END SSH SIGNATURE-----"
},
{
"index": 3,
"timestamp": "2026-07-06T04:28:14.273994+00:00",
"action": "decision",
"data": {
"context": "How to enforce priority validation on both construction and later assignment for a mutable dataclass",
"choice": "Override Task.__setattr__ to validate the priority field, coercing valid strings ('low'/'medium'/'high') to Priority enum members and raising ValueError otherwise",
"rationale": "Dataclass __init__ assigns fields via __setattr__, so one override covers both creation and mutation without switching to frozen dataclass or properties; string coercion keeps the public API forgiving while guaranteeing the stored value is always a Priority member so to_dict/from_dict behavior is unchanged"
},
"prev_hash": "beb25eb72c25fb4ee8f4ae9190239d15fd4fb0183538a89dfe6c5311ee04c071",
"hash": "2c4f3813f8de36cf7c16673f2137f723468d4ea9da2144421e9f444d5fbe117a",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEB87rQIM50m+Jn7/ASr2+axzPz6Cx9EOtCp5MdLcYlYjCDMZEYIK0+WLN\nefPhL0SIo1tTOUZw2+MQ7BG3uGZyEC\n-----END SSH SIGNATURE-----"
},
{
"index": 4,
"timestamp": "2026-07-06T04:28:38.280927+00:00",
"action": "file-edit",
"data": {
"path": "tests/test_priority.py",
"operation": "create",
"sha256": "1d5212fc1b2d699eb176df3d8dcacac7d48ca2aec1ce2a231ecdc3067fd552ba"
},
"prev_hash": "2c4f3813f8de36cf7c16673f2137f723468d4ea9da2144421e9f444d5fbe117a",
"hash": "b2256dc12e1bc3aea08654195696564788415e40fe4750e2c039efeaba1f94a5",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEAXdfBxOkh4wzEaqF2f/Ca6rDh0E2R4kHi8cs/1gHga7NWVzBphCIsFeI\nREaV5y70nxXrUWxcAqcaa4I8wguFUO\n-----END SSH SIGNATURE-----"
},
{
"index": 5,
"timestamp": "2026-07-06T04:28:38.374432+00:00",
"action": "decision",
"data": {
"context": "REQ-3 asks TaskStore to gain filter_by_priority, but the method already exists in src/task_store.py",
"choice": "Leave src/task_store.py unchanged and cover the existing method with new tests",
"rationale": "The existing implementation already satisfies the requirement; ForgeProof rules call for focused, minimal changes rather than rewriting working code"
},
"prev_hash": "b2256dc12e1bc3aea08654195696564788415e40fe4750e2c039efeaba1f94a5",
"hash": "407a92317d6fe98dbefa4e56c98d7cbffc7d035951d3c0518921f3445e98d101",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEA9vGx5pybObEFDg5hKfMVi5OVygn4gGFvvbJqRyeJ0QjmkaemLYPyNSD\neOfA7NQYdpLaFIYY49CSrdl0YLyZQC\n-----END SSH SIGNATURE-----"
},
{
"index": 6,
"timestamp": "2026-07-06T04:28:59.838656+00:00",
"action": "test-result",
"data": {
"suite": "pytest",
"passed": 44,
"failed": 0,
"coverage": {
"REQ-1": [
"test_priority_defaults_to_medium",
"test_priority_accepts_all_allowed_values",
"test_priority_accepts_allowed_strings"
],
"REQ-2": [
"test_invalid_priority_string_at_creation_raises",
"test_invalid_priority_type_at_creation_raises",
"test_invalid_priority_on_assignment_raises",
"test_valid_priority_on_assignment",
"test_invalid_priority_in_from_dict_raises"
],
"REQ-3": [
"test_filter_by_priority_returns_matches",
"test_filter_by_priority_no_match"
],
"REQ-4": [
"test_untouched_priority_creation_and_completion",
"test_untouched_priority_serialization_round_trip"
]
},
"failed_tests": []
},
"prev_hash": "407a92317d6fe98dbefa4e56c98d7cbffc7d035951d3c0518921f3445e98d101",
"hash": "e07ee3f54987cc6c5ab4c6ca095054dfa0652277b03e44fabc4a3eef5420aad4",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEC0tuzazRJRKf3TBnyeBn+N7Yled/Y604E1azCK26dtMJUy0vkDmo3puh\nKUAJ1PWircco24TsPiAgk0wd6i5/cG\n-----END SSH SIGNATURE-----"
},
{
"index": 7,
"timestamp": "2026-07-06T04:29:10.976253+00:00",
"action": "lint-result",
"data": {
"tool": "ruff",
"errors": 0,
"warnings": 0
},
"prev_hash": "e07ee3f54987cc6c5ab4c6ca095054dfa0652277b03e44fabc4a3eef5420aad4",
"hash": "2b04a5461e90580c8c75cd21a15324d465b3e9603b8c94a230e6769bf232ba4c",
"signature": "-----BEGIN SSH SIGNATURE-----\nU1NIU0lHAAAAAQAAADMAAAALc3NoLWVkMjU1MTkAAAAg+oHvrJFS7a9DnrWjvpyvVEI/iz\nREJUsu7RBNAYIem/MAAAAKZm9yZ2Vwcm9vZgAAAAAAAAAGc2hhNTEyAAAAUwAAAAtzc2gt\nZWQyNTUxOQAAAEDUwu+EPrmgxXaF+sb2xUHD7WWzTudDmK5q3I/vg1NoFp6D/hBAuNA2LY\nxrNxFSRpeIL7YLm0G+P69cVcK84r4D\n-----END SSH SIGNATURE-----"
}
]
17 changes: 17 additions & 0 deletions src/task.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,23 @@ class Task:
completed_at: datetime | None = None
due_date: datetime | None = None

def __setattr__(self, name: str, value: object) -> None:
if name == "priority":
if isinstance(value, str):
try:
value = Priority(value)
except ValueError:
raise ValueError(
f"invalid priority: {value!r}; allowed values are "
f"{', '.join(p.value for p in Priority)}"
) from None
elif not isinstance(value, Priority):
raise ValueError(
f"invalid priority: {value!r}; allowed values are "
f"{', '.join(p.value for p in Priority)}"
)
super().__setattr__(name, value)

def complete(self) -> None:
"""Mark the task as done. No-op if already done."""
if self.status == Status.DONE:
Expand Down
111 changes: 111 additions & 0 deletions tests/test_priority.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
"""Tests for Task priority levels (issue #9)."""

import pytest

from src.task import Priority, Status, Task
from src.task_store import TaskStore


# REQ-1: priority attribute with allowed values low, medium, high; default medium


def test_priority_defaults_to_medium():
task = Task(title="No priority given")
assert task.priority == Priority.MEDIUM


def test_priority_accepts_all_allowed_values():
for priority in (Priority.LOW, Priority.MEDIUM, Priority.HIGH):
task = Task(title="Explicit priority", priority=priority)
assert task.priority == priority


def test_priority_accepts_allowed_strings():
for value in ("low", "medium", "high"):
task = Task(title="String priority", priority=value)
assert task.priority == Priority(value)


# REQ-2: any other priority value raises ValueError


def test_invalid_priority_string_at_creation_raises():
with pytest.raises(ValueError):
Task(title="Bad", priority="urgent")


def test_invalid_priority_type_at_creation_raises():
with pytest.raises(ValueError):
Task(title="Bad", priority=3)


def test_invalid_priority_on_assignment_raises():
task = Task(title="Good")
with pytest.raises(ValueError):
task.priority = "banana"


def test_valid_priority_on_assignment():
task = Task(title="Good")
task.priority = Priority.HIGH
assert task.priority == Priority.HIGH
task.priority = "low"
assert task.priority == Priority.LOW


def test_invalid_priority_in_from_dict_raises():
data = {
"title": "Bad",
"priority": "critical",
"status": "todo",
"tags": [],
"created_at": "2026-01-01T00:00:00+00:00",
"completed_at": None,
}
with pytest.raises(ValueError):
Task.from_dict(data)


# REQ-3: TaskStore.filter_by_priority returns tasks matching the given priority


def test_filter_by_priority_returns_matches():
store = TaskStore()
low_id = store.add(Task(title="Low", priority=Priority.LOW))
store.add(Task(title="Medium"))
high_id = store.add(Task(title="High", priority=Priority.HIGH))

high_tasks = store.filter_by_priority(Priority.HIGH)
assert [(tid, t.title) for tid, t in high_tasks] == [(high_id, "High")]

low_tasks = store.filter_by_priority(Priority.LOW)
assert [(tid, t.title) for tid, t in low_tasks] == [(low_id, "Low")]


def test_filter_by_priority_no_match():
store = TaskStore()
store.add(Task(title="Medium only"))
assert store.filter_by_priority(Priority.HIGH) == []


# REQ-4: existing behavior unchanged for tasks that never set a priority


def test_untouched_priority_creation_and_completion():
task = Task(title="Plain task", description="desc", tags=["a"])
assert task.priority == Priority.MEDIUM
assert task.status == Status.TODO
task.complete()
assert task.status == Status.DONE
assert task.completed_at is not None


def test_untouched_priority_serialization_round_trip():
original = Task(title="Plain task")
original.complete()
d = original.to_dict()
assert d["priority"] == "medium"
rebuilt = Task.from_dict(d)
assert rebuilt.priority == Priority.MEDIUM
assert rebuilt.status == original.status
assert rebuilt.completed_at == original.completed_at