Coverage for little_loops/goals_parser.py: 0%

1"""Parser for ll-goals.md product goals document.

2

3 Provides structured access to product goals including persona and priorities.

4"""

5

6 from __future__ import annotations

7

8 from dataclasses import dataclass, field

9 from pathlib import Path

10 from typing import Any

11

12 import yaml

13

14

15@dataclass

16 class Persona:

17 """Primary user persona.

18

19 Attributes:

20 id: Unique identifier for the persona

21 name: Display name

22 role: Description of the persona's role

23 """

24

25 id: str

26 name: str

27 role: str

28

29 @classmethod

30 def from_dict(cls, data: dict[str, Any]) -> Persona:

31 """Create Persona from dictionary.

32

33 Args:

34 data: Dictionary with persona fields

35

36 Returns:

37 Persona instance

38 """

39 return cls(

40 id=data.get("id", "user"),

41 name=data.get("name", "User"),

42 role=data.get("role", ""),

43 )

44

45

46@dataclass

47 class Priority:

48 """Strategic priority.

49

50 Attributes:

51 id: Unique identifier for the priority

52 name: Description of the priority

53 """

54

55 id: str

56 name: str

57

58 @classmethod

59 def from_dict(cls, data: dict[str, Any], index: int = 0) -> Priority:

60 """Create Priority from dictionary.

61

62 Args:

63 data: Dictionary with priority fields

64 index: Index for generating default ID

65

66 Returns:

67 Priority instance

68 """

69 return cls(

70 id=data.get("id", f"priority-{index}"),

71 name=data.get("name", ""),

72 )

73

74

75@dataclass

76 class ProductGoals:

77 """Parsed product goals from ll-goals.md.

78

79 Attributes:

80 version: Schema version of the goals file

81 persona: Primary user persona (may be None)

82 priorities: List of strategic priorities

83 raw_content: Full markdown content for LLM context

84 """

85

86 version: str

87 persona: Persona | None

88 priorities: list[Priority] = field(default_factory=list)

89 raw_content: str = ""

90

91 @classmethod

92 def from_file(cls, path: Path) -> ProductGoals | None:

93 """Parse goals from ll-goals.md file.

94

95 Args:

96 path: Path to the goals file

97

98 Returns:

99 ProductGoals instance or None if file doesn't exist or is invalid

100 """

101 if not path.exists():

102 return None

103

104 try:

105 content = path.read_text(encoding="utf-8")

106 except (OSError, UnicodeDecodeError):

107 return None

108

109 return cls.from_content(content)

110

111 @classmethod

112 def from_content(cls, content: str) -> ProductGoals | None:

113 """Parse goals from string content.

114

115 Args:

116 content: Raw file content

117

118 Returns:

119 ProductGoals instance or None if content is invalid

120 """

121 if not content or not content.startswith("---"):

122 return None

123

124 # Find closing frontmatter delimiter

125 parts = content.split("---", 2)

126 if len(parts) < 3:

127 return None

128

129 frontmatter_text = parts[1].strip()

130 if not frontmatter_text:

131 return None

132

133 try:

134 frontmatter = yaml.safe_load(frontmatter_text)

135 except yaml.YAMLError:

136 return None

137

138 if not isinstance(frontmatter, dict):

139 return None

140

141 # Parse persona

142 persona = None

143 persona_data = frontmatter.get("persona")

144 if persona_data and isinstance(persona_data, dict):

145 persona = Persona.from_dict(persona_data)

146

147 # Parse priorities

148 priorities: list[Priority] = []

149 priorities_data = frontmatter.get("priorities", [])

150 if isinstance(priorities_data, list):

151 for i, p in enumerate(priorities_data, 1):

152 if isinstance(p, dict):

153 priorities.append(Priority.from_dict(p, i))

154

155 return cls(

156 version=str(frontmatter.get("version", "1.0")),

157 persona=persona,

158 priorities=priorities,

159 raw_content=content,

160 )

161

162 def is_valid(self) -> bool:

163 """Check if goals have minimum required content.

164

165 Returns:

166 True if persona and at least one priority are defined

167 """

168 return self.persona is not None and len(self.priorities) > 0

169

170

171 def validate_goals(goals: ProductGoals) -> list[str]:

172 """Validate product goals and return warnings.

173

174 Args:

175 goals: ProductGoals instance to validate

176

177 Returns:

178 List of validation warning messages (empty if valid)

179 """

180 warnings: list[str] = []

181

182 if not goals.persona:

183 warnings.append("No persona defined - product analysis may be less effective")

184

185 if not goals.priorities:

186 warnings.append("No priorities defined - cannot assess goal alignment")

187 elif len(goals.priorities) > 5:

188 warnings.append("More than 5 priorities defined - consider focusing on top priorities")

189

190 if "[NEEDS REVIEW]" in goals.raw_content:

191 warnings.append("File contains [NEEDS REVIEW] placeholders - please update")

192

193 # Check for template placeholder priority names

194 template_placeholders = {"Primary goal description", "Secondary goal description"}

195 for i, priority in enumerate(goals.priorities, 1):

196 if not priority.name or priority.name in template_placeholders:

197 warnings.append(f"Priority {i} has placeholder or empty name")

198

199 # Check for template persona

200 if goals.persona and goals.persona.role == "Software developer using this project":

201 warnings.append("Persona has template description - please customize")

202

203 return warnings