Coverage for src / moai_adk / core / spec_status_manager.py: 8.68%
219 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
« prev ^ index » next coverage.py v7.12.0, created at 2025-11-20 20:52 +0900
1"""SPEC Status Manager
3Automated management of SPEC status transitions from 'draft' to 'completed'
4based on implementation completion detection and validation criteria.
5"""
7import logging
8import re
9from datetime import datetime
10from pathlib import Path
11from typing import Dict, Set
13import yaml
15logger = logging.getLogger(__name__)
18class SpecStatusManager:
19 """Manages SPEC status detection and updates based on implementation completion"""
21 def __init__(self, project_root: Path):
22 """Initialize the SPEC status manager
24 Args:
25 project_root: Root directory of the MoAI project
26 """
27 self.project_root = Path(project_root)
28 self.specs_dir = self.project_root / ".moai" / "specs"
29 self.src_dir = self.project_root / "src"
30 self.tests_dir = self.project_root / "tests"
31 self.docs_dir = self.project_root / "docs"
33 # Validation criteria (configurable)
34 self.validation_criteria = {
35 "min_code_coverage": 0.85, # 85% minimum coverage
36 "require_acceptance_criteria": True,
37 "min_implementation_age_days": 0, # Days since last implementation
38 }
40 def detect_draft_specs(self) -> Set[str]:
41 """Detect all SPEC files with 'draft' status
43 Returns:
44 Set of SPEC IDs that have draft status
45 """
46 draft_specs = set()
48 if not self.specs_dir.exists():
49 logger.warning(f"SPEC directory not found: {self.specs_dir}")
50 return draft_specs
52 for spec_dir in self.specs_dir.iterdir():
53 if spec_dir.is_dir():
54 spec_file = spec_dir / "spec.md"
55 if spec_file.exists():
56 try:
57 # Read frontmatter to check status
58 with open(spec_file, "r", encoding="utf-8") as f:
59 content = f.read()
61 frontmatter = None
63 # Handle JSON-like meta (common in older specs)
64 meta_match = re.search(
65 r"<%?\s*---\s*\n(.*?)\n---\s*%?>", content, re.DOTALL
66 )
67 if meta_match:
68 try:
69 meta_text = meta_match.group(1)
70 # Replace JSON-style quotes and parse as YAML
71 meta_text = meta_text.replace('"', "").replace("'", "")
72 frontmatter = yaml.safe_load("{" + meta_text + "}")
73 except Exception as e:
74 logger.debug(
75 f"JSON meta parsing failed for {spec_dir.name}: {e}"
76 )
78 # Handle regular YAML frontmatter
79 elif content.startswith("---"):
80 end_marker = content.find("---", 3)
81 if end_marker != -1:
82 frontmatter_text = content[3:end_marker].strip()
83 try:
84 frontmatter = yaml.safe_load(frontmatter_text)
85 except yaml.YAMLError as e:
86 logger.warning(
87 f"YAML parsing error for {spec_dir.name}: {e}"
88 )
89 # Try to fix common issues (like @ in author field)
90 try:
91 # Replace problematic @author entries
92 fixed_text = frontmatter_text
93 if "author: @" in fixed_text:
94 fixed_text = re.sub(
95 r"author:\s*@(\w+)",
96 r'author: "\1"',
97 fixed_text,
98 )
99 frontmatter = yaml.safe_load(fixed_text)
100 except yaml.YAMLError:
101 logger.error(
102 f"Could not parse YAML for {spec_dir.name} even after fixes"
103 )
104 continue
106 if frontmatter and frontmatter.get("status") == "draft":
107 spec_id = spec_dir.name
108 draft_specs.add(spec_id)
109 logger.debug(f"Found draft SPEC: {spec_id}")
111 except Exception as e:
112 logger.error(f"Error reading SPEC {spec_dir.name}: {e}")
114 logger.info(f"Found {len(draft_specs)} draft SPECs")
115 return draft_specs
117 def is_spec_implementation_completed(self, spec_id: str) -> bool:
118 """Check if a SPEC's implementation is complete
120 Args:
121 spec_id: The SPEC identifier (e.g., "SPEC-001")
123 Returns:
124 True if implementation is complete, False otherwise
125 """
126 spec_dir = self.specs_dir / spec_id
127 spec_file = spec_dir / "spec.md"
129 if not spec_file.exists():
130 logger.warning(f"SPEC file not found: {spec_file}")
131 return False
133 try:
134 # Check basic implementation status
135 spec_dir = spec_file.parent
137 # Check for implementation files
138 src_files = (
139 list(spec_dir.rglob("*.py"))
140 if (spec_dir.parent.parent / "src").exists()
141 else []
142 )
144 # Check for test files
145 test_dir = spec_dir.parent.parent / "tests"
146 test_files = (
147 list(test_dir.rglob(f"test_*{spec_id.lower()}*.py"))
148 if test_dir.exists()
149 else []
150 )
152 # Simple completion criteria
153 has_code = len(src_files) > 0
154 has_tests = len(test_files) > 0
156 # Check if SPEC has acceptance criteria
157 with open(spec_file, "r", encoding="utf-8") as f:
158 spec_content = f.read()
159 has_acceptance_criteria = "Acceptance Criteria" in spec_content
161 # Overall completion check
162 is_complete = has_code and has_tests and has_acceptance_criteria
164 logger.info(
165 f"SPEC {spec_id} implementation status: {'COMPLETE' if is_complete else 'INCOMPLETE'}"
166 )
167 return is_complete
169 except Exception as e:
170 logger.error(f"Error checking SPEC {spec_id} completion: {e}")
171 return False
173 def update_spec_status(self, spec_id: str, new_status: str) -> bool:
174 """Update SPEC status in frontmatter
176 Args:
177 spec_id: The SPEC identifier
178 new_status: New status value ('completed', 'draft', etc.)
180 Returns:
181 True if update successful, False otherwise
182 """
183 spec_dir = self.specs_dir / spec_id
184 spec_file = spec_dir / "spec.md"
186 if not spec_file.exists():
187 logger.error(f"SPEC file not found: {spec_file}")
188 return False
190 try:
191 with open(spec_file, "r", encoding="utf-8") as f:
192 content = f.read()
194 # Extract and update frontmatter
195 if content.startswith("---"):
196 end_marker = content.find("---", 3)
197 if end_marker != -1:
198 frontmatter_text = content[3:end_marker].strip()
199 try:
200 frontmatter = yaml.safe_load(frontmatter_text) or {}
201 except yaml.YAMLError as e:
202 logger.warning(f"YAML parsing error for {spec_id}: {e}")
203 # Try to fix common issues
204 try:
205 fixed_text = frontmatter_text
206 if "author: @" in fixed_text:
207 fixed_text = re.sub(
208 r"author:\s*@(\w+)", r'author: "\1"', fixed_text
209 )
210 frontmatter = yaml.safe_load(fixed_text) or {}
211 except yaml.YAMLError:
212 logger.error(
213 f"Could not parse YAML for {spec_id} even after fixes"
214 )
215 return False
217 # Update status
218 frontmatter["status"] = new_status
220 # Bump version if completing
221 if new_status == "completed":
222 frontmatter["version"] = self._bump_version(
223 frontmatter.get("version", "0.1.0")
224 )
225 frontmatter["updated"] = datetime.now().strftime("%Y-%m-%d")
227 # Reconstruct the file
228 new_frontmatter = yaml.dump(frontmatter, default_flow_style=False)
229 new_content = f"---\n{new_frontmatter}---{content[end_marker+3:]}"
231 # Write back to file
232 with open(spec_file, "w", encoding="utf-8") as f:
233 f.write(new_content)
235 logger.info(f"Updated SPEC {spec_id} status to {new_status}")
236 return True
238 except Exception as e:
239 logger.error(f"Error updating SPEC {spec_id} status: {e}")
240 return False
242 def get_completion_validation_criteria(self) -> Dict:
243 """Get the current validation criteria for SPEC completion
245 Returns:
246 Dictionary of validation criteria
247 """
248 return self.validation_criteria.copy()
250 def validate_spec_for_completion(self, spec_id: str) -> Dict:
251 """Validate if a SPEC is ready for completion
253 Args:
254 spec_id: The SPEC identifier
256 Returns:
257 Dictionary with validation results:
258 {
259 'is_ready': bool,
260 'criteria_met': Dict[str, bool],
261 'issues': List[str],
262 'recommendations': List[str]
263 }
264 """
265 result = {
266 "is_ready": False,
267 "criteria_met": {},
268 "issues": [],
269 "recommendations": [],
270 }
272 try:
273 spec_dir = self.specs_dir / spec_id
274 spec_file = spec_dir / "spec.md"
276 if not spec_file.exists():
277 result["issues"].append(f"SPEC file not found: {spec_file}")
278 return result
280 # Check implementation status
281 criteria_checks = {}
283 # Check for code implementation
284 spec_dir = spec_file.parent
285 src_dir = spec_dir.parent.parent / "src"
286 criteria_checks["code_implemented"] = (
287 src_dir.exists() and len(list(src_dir.rglob("*.py"))) > 0
288 )
289 if not criteria_checks["code_implemented"]:
290 result["issues"].append("No source code files found")
292 # Check for test implementation
293 test_dir = spec_dir.parent.parent / "tests"
294 test_files = list(test_dir.rglob("test_*.py")) if test_dir.exists() else []
295 criteria_checks["test_implemented"] = len(test_files) > 0
296 if not criteria_checks["test_implemented"]:
297 result["issues"].append("No test files found")
299 # Check for acceptance criteria
300 criteria_checks["tasks_completed"] = self._check_acceptance_criteria(
301 spec_file
302 )
303 if not criteria_checks["tasks_completed"]:
304 result["issues"].append("Missing acceptance criteria section")
306 # 4. Acceptance criteria present
307 criteria_checks["has_acceptance_criteria"] = (
308 self._check_acceptance_criteria(spec_file)
309 )
310 if (
311 not criteria_checks["has_acceptance_criteria"]
312 and self.validation_criteria["require_acceptance_criteria"]
313 ):
314 result["issues"].append("Missing acceptance criteria section")
316 # 5. Documentation sync
317 criteria_checks["docs_synced"] = self._check_documentation_sync(spec_id)
318 if not criteria_checks["docs_synced"]:
319 result["recommendations"].append(
320 "Consider running /alfred:3-sync to update documentation"
321 )
323 result["criteria_met"] = criteria_checks
324 result["is_ready"] = all(criteria_checks.values())
326 # Add recommendations
327 if result["is_ready"]:
328 result["recommendations"].append(
329 "SPEC is ready for completion. Consider updating status to 'completed'"
330 )
332 except Exception as e:
333 logger.error(f"Error validating SPEC {spec_id}: {e}")
334 result["issues"].append(f"Validation error: {e}")
336 return result
338 def batch_update_completed_specs(self) -> Dict:
339 """Batch update all draft SPECs that have completed implementations
341 Returns:
342 Dictionary with update results:
343 {
344 'updated': List[str], # Successfully updated SPEC IDs
345 'failed': List[str], # Failed SPEC IDs with errors
346 'skipped': List[str] # Incomplete SPEC IDs
347 }
348 """
349 results = {"updated": [], "failed": [], "skipped": []}
351 draft_specs = self.detect_draft_specs()
352 logger.info(f"Checking {len(draft_specs)} draft SPECs for completion")
354 for spec_id in draft_specs:
355 try:
356 # Validate first
357 validation = self.validate_spec_for_completion(spec_id)
359 if validation["is_ready"]:
360 # Update status
361 if self.update_spec_status(spec_id, "completed"):
362 results["updated"].append(spec_id)
363 logger.info(f"Updated SPEC {spec_id} to completed")
364 else:
365 results["failed"].append(spec_id)
366 logger.error(f"Failed to update SPEC {spec_id}")
367 else:
368 results["skipped"].append(spec_id)
369 logger.debug(
370 f"SPEC {spec_id} not ready for completion: {validation['issues']}"
371 )
373 except Exception as e:
374 results["failed"].append(spec_id)
375 logger.error(f"Error processing SPEC {spec_id}: {e}")
377 logger.info(
378 f"Batch update complete: {len(results['updated'])} updated, "
379 f"{len(results['failed'])} failed, "
380 f"{len(results['skipped'])} skipped"
381 )
382 return results
384 # Private helper methods
386 def _check_acceptance_criteria(self, spec_file: Path) -> bool:
387 """
388 Check if SPEC file contains acceptance criteria
390 Args:
391 spec_file: Path to SPEC file
393 Returns:
394 True if acceptance criteria present
395 """
396 try:
397 with open(spec_file, "r", encoding="utf-8") as f:
398 content = f.read()
400 # Look for acceptance criteria section
401 acceptance_patterns = [
402 r"##\s*Acceptance\s+Criteria",
403 r"###\s*Acceptance\s+Criteria",
404 r"##\s*验收\s+标准",
405 r"###\s*验收\s+标准",
406 ]
408 for pattern in acceptance_patterns:
409 if re.search(pattern, content, re.IGNORECASE):
410 return True
412 return False
414 except Exception as e:
415 logger.error(f"Error checking acceptance criteria in {spec_file}: {e}")
416 return False
418 def _check_documentation_sync(self, spec_id: str) -> bool:
419 """
420 Check if documentation is synchronized with implementation
422 Args:
423 spec_id: The SPEC identifier
425 Returns:
426 True if documentation appears synchronized
427 """
428 try:
429 # Simple heuristic: check if docs exist and are recent
430 docs_dir = self.project_root / "docs"
431 if not docs_dir.exists():
432 return True # No docs to sync
434 # Check if there are any doc files related to this SPEC
435 spec_docs = list(docs_dir.rglob(f"*{spec_id.lower()}*"))
436 if not spec_docs:
437 return True # No specific docs for this SPEC
439 # Basic check - assume docs are in sync if they exist
440 return True
442 except Exception as e:
443 logger.error(f"Error checking documentation sync for {spec_id}: {e}")
444 return False
446 def _run_additional_validations(self, spec_id: str) -> bool:
447 """Run additional validation checks for SPEC completion
449 Args:
450 spec_id: The SPEC identifier
452 Returns:
453 True if all additional validations pass
454 """
455 # Add any additional validation logic here
456 # For now, return True as default
457 return True
459 def _bump_version(self, current_version: str) -> str:
460 """Bump version to indicate completion
462 Args:
463 current_version: Current version string
465 Returns:
466 New version string
467 """
468 try:
469 # Parse current version - strip quotes if present
470 version = str(current_version).strip("\"'")
472 if version.startswith("0."):
473 # Major version bump for completion
474 return "1.0.0"
475 else:
476 # Minor version bump for updates
477 parts = version.split(".")
478 if len(parts) >= 2:
479 try:
480 minor = int(parts[1]) + 1
481 return f"{parts[0]}.{minor}.0"
482 except ValueError:
483 # If parsing fails, default to 1.0.0
484 return "1.0.0"
485 else:
486 return f"{version}.1"
488 except Exception:
489 # Fallback to 1.0.0
490 return "1.0.0"