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

1"""SPEC Status Manager 

2 

3Automated management of SPEC status transitions from 'draft' to 'completed' 

4based on implementation completion detection and validation criteria. 

5""" 

6 

7import logging 

8import re 

9from datetime import datetime 

10from pathlib import Path 

11from typing import Dict, Set 

12 

13import yaml 

14 

15logger = logging.getLogger(__name__) 

16 

17 

18class SpecStatusManager: 

19 """Manages SPEC status detection and updates based on implementation completion""" 

20 

21 def __init__(self, project_root: Path): 

22 """Initialize the SPEC status manager 

23 

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" 

32 

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 } 

39 

40 def detect_draft_specs(self) -> Set[str]: 

41 """Detect all SPEC files with 'draft' status 

42 

43 Returns: 

44 Set of SPEC IDs that have draft status 

45 """ 

46 draft_specs = set() 

47 

48 if not self.specs_dir.exists(): 

49 logger.warning(f"SPEC directory not found: {self.specs_dir}") 

50 return draft_specs 

51 

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() 

60 

61 frontmatter = None 

62 

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 ) 

77 

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 

105 

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}") 

110 

111 except Exception as e: 

112 logger.error(f"Error reading SPEC {spec_dir.name}: {e}") 

113 

114 logger.info(f"Found {len(draft_specs)} draft SPECs") 

115 return draft_specs 

116 

117 def is_spec_implementation_completed(self, spec_id: str) -> bool: 

118 """Check if a SPEC's implementation is complete 

119 

120 Args: 

121 spec_id: The SPEC identifier (e.g., "SPEC-001") 

122 

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" 

128 

129 if not spec_file.exists(): 

130 logger.warning(f"SPEC file not found: {spec_file}") 

131 return False 

132 

133 try: 

134 # Check basic implementation status 

135 spec_dir = spec_file.parent 

136 

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 ) 

143 

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 ) 

151 

152 # Simple completion criteria 

153 has_code = len(src_files) > 0 

154 has_tests = len(test_files) > 0 

155 

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 

160 

161 # Overall completion check 

162 is_complete = has_code and has_tests and has_acceptance_criteria 

163 

164 logger.info( 

165 f"SPEC {spec_id} implementation status: {'COMPLETE' if is_complete else 'INCOMPLETE'}" 

166 ) 

167 return is_complete 

168 

169 except Exception as e: 

170 logger.error(f"Error checking SPEC {spec_id} completion: {e}") 

171 return False 

172 

173 def update_spec_status(self, spec_id: str, new_status: str) -> bool: 

174 """Update SPEC status in frontmatter 

175 

176 Args: 

177 spec_id: The SPEC identifier 

178 new_status: New status value ('completed', 'draft', etc.) 

179 

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" 

185 

186 if not spec_file.exists(): 

187 logger.error(f"SPEC file not found: {spec_file}") 

188 return False 

189 

190 try: 

191 with open(spec_file, "r", encoding="utf-8") as f: 

192 content = f.read() 

193 

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 

216 

217 # Update status 

218 frontmatter["status"] = new_status 

219 

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") 

226 

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:]}" 

230 

231 # Write back to file 

232 with open(spec_file, "w", encoding="utf-8") as f: 

233 f.write(new_content) 

234 

235 logger.info(f"Updated SPEC {spec_id} status to {new_status}") 

236 return True 

237 

238 except Exception as e: 

239 logger.error(f"Error updating SPEC {spec_id} status: {e}") 

240 return False 

241 

242 def get_completion_validation_criteria(self) -> Dict: 

243 """Get the current validation criteria for SPEC completion 

244 

245 Returns: 

246 Dictionary of validation criteria 

247 """ 

248 return self.validation_criteria.copy() 

249 

250 def validate_spec_for_completion(self, spec_id: str) -> Dict: 

251 """Validate if a SPEC is ready for completion 

252 

253 Args: 

254 spec_id: The SPEC identifier 

255 

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 } 

271 

272 try: 

273 spec_dir = self.specs_dir / spec_id 

274 spec_file = spec_dir / "spec.md" 

275 

276 if not spec_file.exists(): 

277 result["issues"].append(f"SPEC file not found: {spec_file}") 

278 return result 

279 

280 # Check implementation status 

281 criteria_checks = {} 

282 

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") 

291 

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") 

298 

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") 

305 

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") 

315 

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 ) 

322 

323 result["criteria_met"] = criteria_checks 

324 result["is_ready"] = all(criteria_checks.values()) 

325 

326 # Add recommendations 

327 if result["is_ready"]: 

328 result["recommendations"].append( 

329 "SPEC is ready for completion. Consider updating status to 'completed'" 

330 ) 

331 

332 except Exception as e: 

333 logger.error(f"Error validating SPEC {spec_id}: {e}") 

334 result["issues"].append(f"Validation error: {e}") 

335 

336 return result 

337 

338 def batch_update_completed_specs(self) -> Dict: 

339 """Batch update all draft SPECs that have completed implementations 

340 

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": []} 

350 

351 draft_specs = self.detect_draft_specs() 

352 logger.info(f"Checking {len(draft_specs)} draft SPECs for completion") 

353 

354 for spec_id in draft_specs: 

355 try: 

356 # Validate first 

357 validation = self.validate_spec_for_completion(spec_id) 

358 

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 ) 

372 

373 except Exception as e: 

374 results["failed"].append(spec_id) 

375 logger.error(f"Error processing SPEC {spec_id}: {e}") 

376 

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 

383 

384 # Private helper methods 

385 

386 def _check_acceptance_criteria(self, spec_file: Path) -> bool: 

387 """ 

388 Check if SPEC file contains acceptance criteria 

389 

390 Args: 

391 spec_file: Path to SPEC file 

392 

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() 

399 

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 ] 

407 

408 for pattern in acceptance_patterns: 

409 if re.search(pattern, content, re.IGNORECASE): 

410 return True 

411 

412 return False 

413 

414 except Exception as e: 

415 logger.error(f"Error checking acceptance criteria in {spec_file}: {e}") 

416 return False 

417 

418 def _check_documentation_sync(self, spec_id: str) -> bool: 

419 """ 

420 Check if documentation is synchronized with implementation 

421 

422 Args: 

423 spec_id: The SPEC identifier 

424 

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 

433 

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 

438 

439 # Basic check - assume docs are in sync if they exist 

440 return True 

441 

442 except Exception as e: 

443 logger.error(f"Error checking documentation sync for {spec_id}: {e}") 

444 return False 

445 

446 def _run_additional_validations(self, spec_id: str) -> bool: 

447 """Run additional validation checks for SPEC completion 

448 

449 Args: 

450 spec_id: The SPEC identifier 

451 

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 

458 

459 def _bump_version(self, current_version: str) -> str: 

460 """Bump version to indicate completion 

461 

462 Args: 

463 current_version: Current version string 

464 

465 Returns: 

466 New version string 

467 """ 

468 try: 

469 # Parse current version - strip quotes if present 

470 version = str(current_version).strip("\"'") 

471 

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" 

487 

488 except Exception: 

489 # Fallback to 1.0.0 

490 return "1.0.0"