main
py 207 lines 6.75 KB
Raw
1 #!/usr/bin/env python3
2 """Validate a squadscope.topic.yml config file against the topic schema.
3
4 Usage:
5 python scripts/validate_topic_config.py <path-to-yaml>
6
7 Exit codes:
8 0 — config is valid
9 1 — config is invalid (errors printed to stderr)
10
11 Schema fields:
12 topic (required):
13 id — URL-safe identifier (lowercase alphanumeric + hyphens)
14 name — Human-readable display name
15 description — Short description for the topic
16
17 queries (required):
18 primary — List of GitHub search queries (at least one required)
19 secondary — Optional list of supplemental queries
20
21 scoring (optional, has defaults):
22 min_stars, min_stars_gained, max_age_days, min_relevance_score
23 language_boost — dict of language → multiplier
24 topic_relevance — list of relevant GitHub topics
25
26 quality (optional, has defaults):
27 min_repos_per_week, max_repos_per_week, min_quality_score
28
29 learning (optional, has defaults):
30 wisdom_file, skills_dir, prediction_file, scorecard_dir
31 """
32
33 from __future__ import annotations
34
35 import re
36 import sys
37 from pathlib import Path
38 from typing import Dict, List
39
40 import yaml
41 from pydantic import BaseModel, Field, field_validator, model_validator
42
43 # --- Pydantic Models ---
44
45 URL_SAFE_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
46
47
48 class TopicInfo(BaseModel):
49 """Core topic identification. All fields required."""
50
51 id: str = Field(..., description="URL-safe identifier (lowercase alphanumeric + hyphens)")
52 name: str = Field(..., description="Human-readable display name")
53 description: str = Field("", description="Short description of the topic")
54
55 @field_validator("id")
56 @classmethod
57 def id_must_be_url_safe(cls, v: str) -> str:
58 if not URL_SAFE_RE.match(v):
59 raise ValueError(
60 f"topic.id must be URL-safe (lowercase alphanumeric + hyphens), got: '{v}'"
61 )
62 return v
63
64 @field_validator("name")
65 @classmethod
66 def name_not_empty(cls, v: str) -> str:
67 if not v.strip():
68 raise ValueError("topic.name must not be empty")
69 return v
70
71
72 class Queries(BaseModel):
73 """Search queries for GitHub API. At least one primary query is required."""
74
75 primary: List[str] = Field(
76 ..., min_length=1, description="Primary search queries (at least one)"
77 )
78 secondary: List[str] = Field(default_factory=list, description="Optional secondary queries")
79
80 @field_validator("primary")
81 @classmethod
82 def primary_not_empty_strings(cls, v: List[str]) -> List[str]:
83 for i, q in enumerate(v):
84 if not q.strip():
85 raise ValueError(f"queries.primary[{i}] must not be an empty string")
86 return v
87
88
89 class Scoring(BaseModel):
90 """Scoring thresholds and boosts. All fields have sensible defaults."""
91
92 min_stars: int = Field(default=20, ge=0, description="Minimum star count")
93 min_stars_gained: int = Field(default=10, ge=0, description="Minimum stars gained in period")
94 max_age_days: int = Field(default=365, ge=1, le=3650, description="Max repo age in days")
95 min_relevance_score: int = Field(
96 default=40, ge=0, le=100, description="Minimum relevance score (0-100)"
97 )
98 language_boost: Dict[str, float] = Field(
99 default_factory=dict, description="Language → score multiplier"
100 )
101 topic_relevance: List[str] = Field(
102 default_factory=list, description="GitHub topics that boost relevance"
103 )
104
105 @field_validator("language_boost")
106 @classmethod
107 def boost_values_reasonable(cls, v: Dict[str, float]) -> Dict[str, float]:
108 for lang, boost in v.items():
109 if boost < 0.1 or boost > 10.0:
110 raise ValueError(
111 f"scoring.language_boost['{lang}'] must be between 0.1 and 10.0, got {boost}"
112 )
113 return v
114
115
116 class Quality(BaseModel):
117 """Quality gates for output. All fields have sensible defaults."""
118
119 min_repos_per_week: int = Field(
120 default=5, ge=1, description="Minimum repos to include per week"
121 )
122 max_repos_per_week: int = Field(
123 default=30, ge=1, description="Maximum repos to include per week"
124 )
125 min_quality_score: int = Field(
126 default=60, ge=0, le=100, description="Minimum quality score (0-100)"
127 )
128
129 @model_validator(mode="after")
130 def min_less_than_max(self) -> "Quality":
131 if self.min_repos_per_week > self.max_repos_per_week:
132 raise ValueError(
133 f"quality.min_repos_per_week ({self.min_repos_per_week}) "
134 f"must be <= max_repos_per_week ({self.max_repos_per_week})"
135 )
136 return self
137
138
139 class Learning(BaseModel):
140 """Paths for learning/feedback loop artifacts. Supports {topic_id} placeholder."""
141
142 wisdom_file: str = Field(
143 default="topics/{topic_id}/wisdom.md", description="Path to wisdom markdown"
144 )
145 skills_dir: str = Field(
146 default="topics/{topic_id}/skills/", description="Directory for learned skills"
147 )
148 prediction_file: str = Field(
149 default="topics/{topic_id}/predictions.jsonl", description="Path to predictions log"
150 )
151 scorecard_dir: str = Field(
152 default="topics/{topic_id}/scorecards/", description="Directory for scorecards"
153 )
154
155
156 class TopicConfig(BaseModel):
157 """Root model for squadscope.topic.yml configuration."""
158
159 topic: TopicInfo
160 queries: Queries
161 scoring: Scoring = Field(default_factory=Scoring)
162 quality: Quality = Field(default_factory=Quality)
163 learning: Learning = Field(default_factory=Learning)
164
165
166 # --- CLI Entrypoint ---
167
168
169 def validate_file(path: str) -> TopicConfig:
170 """Load and validate a YAML topic config file. Returns the validated model."""
171 file_path = Path(path)
172 if not file_path.exists():
173 raise FileNotFoundError(f"Config file not found: {path}")
174
175 with open(file_path, "r", encoding="utf-8") as f:
176 raw = yaml.safe_load(f)
177
178 if not isinstance(raw, dict):
179 raise ValueError("Config file must contain a YAML mapping at the top level")
180
181 return TopicConfig.model_validate(raw)
182
183
184 def main() -> int:
185 if len(sys.argv) != 2:
186 print("Usage: python scripts/validate_topic_config.py <path-to-yaml>", file=sys.stderr)
187 return 1
188
189 path = sys.argv[1]
190 try:
191 config = validate_file(path)
192 print(f"✓ Valid topic config: {config.topic.name} ({config.topic.id})")
193 return 0
194 except FileNotFoundError as e:
195 print(f"✗ Error: {e}", file=sys.stderr)
196 return 1
197 except ValueError as e:
198 print(f"✗ Validation error: {e}", file=sys.stderr)
199 return 1
200 except Exception as e:
201 # Pydantic validation errors
202 print(f"✗ Validation failed:\n{e}", file=sys.stderr)
203 return 1
204
205
206 if __name__ == "__main__":
207 sys.exit(main())