1"""Learning test registry for little-loops.

2

3 Provides CRUD operations for learning test records stored as YAML-frontmatter

4 markdown files under .ll/learning-tests/<slug>.md.

5

6 Reading uses yaml.safe_load on the raw frontmatter block (rather than the

7 hand-rolled parse_frontmatter) because the assertions field is a block

8 sequence of dicts, which parse_frontmatter cannot deserialize.

9"""

10

11 from __future__ import annotations

12

13 import re

14 from dataclasses import dataclass

15 from pathlib import Path

16 from typing import Any, Literal

17

18 import yaml

19

20 from little_loops.frontmatter import update_frontmatter

21 from little_loops.issue_parser import slugify

22

23_DEFAULT_BASE_DIR = Path(".ll") / "learning-tests"

24

25

26@dataclass

27 class Assertion:

28 """A single claim tested against an API or library."""

29

30 claim: str

31 result: Literal["pass", "fail", "untested"]

32

33 def to_dict(self) -> dict[str, str]:

34 return {"claim": self.claim, "result": self.result}

35

36 @classmethod

37 def from_dict(cls, data: dict[str, Any]) -> Assertion:

38 return cls(

39 claim=data["claim"],

40 result=data.get("result", "untested"),

41 )

42

43

44@dataclass

45 class LearnTestRecord:

46 """A learning test record capturing what is known about an API or library."""

47

48 target: str

49 date: str

50 status: Literal["proven", "refuted", "stale"]

51 assertions: list[Assertion]

52 raw_output_path: str | None

53

54 def to_dict(self) -> dict[str, Any]:

55 return {

56 "target": self.target,

57 "date": self.date,

58 "status": self.status,

59 "assertions": [a.to_dict() for a in self.assertions],

60 "raw_output_path": self.raw_output_path,

61 }

62

63 @classmethod

64 def from_dict(cls, data: dict[str, Any]) -> LearnTestRecord:

65 return cls(

66 target=data["target"],

67 date=data["date"],

68 status=data.get("status", "proven"),

69 assertions=[Assertion.from_dict(a) for a in (data.get("assertions") or [])],

70 raw_output_path=data.get("raw_output_path"),

71 )

72

73

74 def _resolve_base(base_dir: Path | None) -> Path:

75 return base_dir if base_dir is not None else Path.cwd() / _DEFAULT_BASE_DIR

76

77

78 def _slug_path(target_slug: str, base_dir: Path) -> Path:

79 return base_dir / f"{target_slug}.md"

80

81

82 def _read_frontmatter_yaml(content: str) -> dict[str, Any] | None:

83 """Extract and parse the YAML frontmatter block using yaml.safe_load."""

84 fm_match = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)

85 if not fm_match:

86 return None

87 return yaml.safe_load(fm_match.group(1)) or {}

88

89

90 def write_record(record: LearnTestRecord, *, base_dir: Path | None = None) -> Path:

91 """Write a LearnTestRecord to .ll/learning-tests/<slug>.md.

92

93 Overwrites any existing file for the same target slug.

94 Returns the path of the written file.

95 """

96 base = _resolve_base(base_dir)

97 base.mkdir(parents=True, exist_ok=True)

98 slug = slugify(record.target)

99 path = _slug_path(slug, base)

100 fm_text = yaml.dump(record.to_dict(), default_flow_style=False, sort_keys=False).strip()

101 path.write_text(f"---\n{fm_text}\n---\n")

102 return path

103

104

105 def read_record(target_slug: str, *, base_dir: Path | None = None) -> LearnTestRecord | None:

106 """Read a LearnTestRecord by slug. Returns None if not found."""

107 base = _resolve_base(base_dir)

108 path = _slug_path(target_slug, base)

109 if not path.exists():

110 return None

111 data = _read_frontmatter_yaml(path.read_text())

112 if data is None:

113 return None

114 return LearnTestRecord.from_dict(data)

115

116

117 def list_records(*, base_dir: Path | None = None) -> list[LearnTestRecord]:

118 """Return all LearnTestRecord objects from the registry directory."""

119 base = _resolve_base(base_dir)

120 if not base.exists():

121 return []

122 records = []

123 for md_file in sorted(base.glob("*.md")):

124 data = _read_frontmatter_yaml(md_file.read_text())

125 if data is not None:

126 records.append(LearnTestRecord.from_dict(data))

127 return records

128

129

130 def mark_stale(target_slug: str, *, base_dir: Path | None = None) -> None:

131 """Set status to 'stale' on an existing record, preserving all other fields."""

132 base = _resolve_base(base_dir)

133 path = _slug_path(target_slug, base)

134 if not path.exists():

135 return

136 updated = update_frontmatter(path.read_text(), {"status": "stale"})

137 path.write_text(updated)

138

139

140 def check_learning_test(target: str, *, base_dir: Path | None = None) -> LearnTestRecord | None:

141 """Look up a record by target name (slugified). Returns None if not found."""

142 return read_record(slugify(target), base_dir=base_dir)