Coverage for src / moai_adk / project / configuration.py: 20.90%

335 statements  

« prev     ^ index     » next       coverage.py v7.12.0, created at 2025-11-20 20:52 +0900

1""" 

2Configuration management for SPEC-REDESIGN-001 

3 

4Handles: 

5- Configuration loading and saving 

6- Smart defaults application 

7- Auto-detection of system values 

8- Configuration validation and coverage 

9""" 

10 

11import json 

12import re 

13from copy import deepcopy 

14from pathlib import Path 

15from typing import Any, Dict, List, Optional 

16 

17 

18class ConfigurationManager: 

19 """Manages project configuration with 31 settings coverage""" 

20 

21 def __init__(self, config_path: Optional[Path] = None): 

22 self.config_path = config_path or Path('.moai/config/config.json') 

23 self.schema = None 

24 self._config_cache = None 

25 

26 def load(self) -> Dict[str, Any]: 

27 """Load configuration from file""" 

28 if self.config_path.exists(): 

29 with open(self.config_path, 'r') as f: 

30 self._config_cache = json.load(f) 

31 return self._config_cache 

32 return {} 

33 

34 def get_smart_defaults(self) -> Dict[str, Any]: 

35 """Get smart defaults""" 

36 engine = SmartDefaultsEngine() 

37 return engine.get_all_defaults() 

38 

39 def get_auto_detect_fields(self) -> List[Dict[str, str]]: 

40 """Get auto-detect field definitions""" 

41 return [ 

42 {'field': 'project.language', 'type': 'auto-detect'}, 

43 {'field': 'project.locale', 'type': 'auto-detect'}, 

44 {'field': 'language.conversation_language_name', 'type': 'auto-detect'}, 

45 {'field': 'project.template_version', 'type': 'auto-detect'}, 

46 {'field': 'moai.version', 'type': 'auto-detect'}, 

47 ] 

48 

49 def save(self, config: Dict[str, Any]) -> bool: 

50 """Save configuration atomically (all or nothing)""" 

51 # Create backup 

52 self._create_backup() 

53 

54 # Validate completeness 

55 if not self._validate_complete(config): 

56 raise ValueError('Configuration missing required fields') 

57 

58 # Ensure directory exists 

59 self.config_path.parent.mkdir(parents=True, exist_ok=True) 

60 

61 # Write atomically 

62 temp_path = self.config_path.with_suffix('.tmp') 

63 try: 

64 with open(temp_path, 'w') as f: 

65 json.dump(config, f, indent=2) 

66 

67 # Atomic rename 

68 temp_path.replace(self.config_path) 

69 self._config_cache = config 

70 return True 

71 except Exception as e: 

72 if temp_path.exists(): 

73 temp_path.unlink() 

74 raise e 

75 

76 def _write_config(self, config: Dict[str, Any]) -> None: 

77 """Internal method for saving configuration""" 

78 self.save(config) 

79 

80 def build_from_responses(self, responses: Dict[str, Any]) -> Dict[str, Any]: 

81 """Build complete configuration from user responses""" 

82 # Start with responses 

83 config = self._parse_responses(responses) 

84 

85 # Apply smart defaults 

86 defaults_engine = SmartDefaultsEngine() 

87 config = defaults_engine.apply_defaults(config) 

88 

89 # Apply auto-detection 

90 auto_detect = AutoDetectionEngine() 

91 config = auto_detect.detect_and_apply(config) 

92 

93 return config 

94 

95 def _parse_responses(self, responses: Dict[str, Any]) -> Dict[str, Any]: 

96 """Parse flat response dict into nested config structure""" 

97 config = { 

98 'user': {}, 

99 'language': {}, 

100 'project': {}, 

101 'git_strategy': { 

102 'personal': {}, 

103 'team': {}, 

104 }, 

105 'constitution': {}, 

106 'moai': {}, 

107 } 

108 

109 # Map responses to config structure 

110 mapping = { 

111 'user_name': ('user', 'name'), 

112 'conversation_language': ('language', 'conversation_language'), 

113 'agent_prompt_language': ('language', 'agent_prompt_language'), 

114 'project_name': ('project', 'name'), 

115 'project_owner': ('project', 'owner'), 

116 'project_description': ('project', 'description'), 

117 'git_strategy_mode': ('git_strategy', 'mode'), 

118 'git_strategy_workflow': ('git_strategy', 'workflow'), 

119 'git_personal_auto_checkpoint': ('git_strategy', 'personal', 'auto_checkpoint'), 

120 'git_personal_push_remote': ('git_strategy', 'personal', 'push_to_remote'), 

121 'git_team_auto_pr': ('git_strategy', 'team', 'auto_pr'), 

122 'git_team_draft_pr': ('git_strategy', 'team', 'draft_pr'), 

123 'test_coverage_target': ('constitution', 'test_coverage_target'), 

124 'enforce_tdd': ('constitution', 'enforce_tdd'), 

125 'documentation_mode': ('project', 'documentation_mode'), 

126 'documentation_depth': ('project', 'documentation_depth'), 

127 } 

128 

129 for response_key, response_value in responses.items(): 

130 if response_key in mapping: 

131 path = mapping[response_key] 

132 self._set_nested(config, path, response_value) 

133 

134 return config 

135 

136 @staticmethod 

137 def _set_nested(config: Dict[str, Any], path: tuple, value: Any) -> None: 

138 """Set nested value in dict using path tuple""" 

139 current = config 

140 for key in path[:-1]: 

141 if key not in current: 

142 current[key] = {} 

143 current = current[key] 

144 current[path[-1]] = value 

145 

146 def _validate_complete(self, config: Dict[str, Any]) -> bool: 

147 """Validate that config has all required fields""" 

148 required_fields = [ 

149 'user.name', 

150 'language.conversation_language', 

151 'language.agent_prompt_language', 

152 'project.name', 

153 'project.owner', 

154 'git_strategy.mode', 

155 'constitution.test_coverage_target', 

156 'constitution.enforce_tdd', 

157 'project.documentation_mode', 

158 ] 

159 

160 flat = self._flatten_config(config) 

161 return all(field in flat for field in required_fields) 

162 

163 @staticmethod 

164 def _flatten_config(config: Dict[str, Any], prefix: str = '') -> Dict[str, Any]: 

165 """Flatten nested config for easier validation""" 

166 result = {} 

167 

168 for key, value in config.items(): 

169 new_key = f'{prefix}.{key}' if prefix else key 

170 if isinstance(value, dict): 

171 result.update(ConfigurationManager._flatten_config(value, new_key)) 

172 else: 

173 result[new_key] = value 

174 

175 return result 

176 

177 def _create_backup(self) -> None: 

178 """Create backup of existing config""" 

179 if self.config_path.exists(): 

180 backup_path = self.config_path.with_suffix('.backup') 

181 with open(self.config_path, 'r') as src: 

182 with open(backup_path, 'w') as dst: 

183 dst.write(src.read()) 

184 

185 

186class SmartDefaultsEngine: 

187 """Applies intelligent default values based on configuration. 

188 

189 Provides 16 smart defaults for configuration fields: 

190 - git_strategy workflows (2) 

191 - git_strategy checkpoints and push behavior (2) 

192 - git_strategy team PR settings (2) 

193 - constitution enforcement (2) 

194 - language settings (1) 

195 - project description (1) 

196 - auto-detect placeholders (5) 

197 - additional settings (1) 

198 """ 

199 

200 def __init__(self): 

201 """Initialize SmartDefaultsEngine with 16+ predefined defaults.""" 

202 self.defaults = { 

203 'git_strategy.personal.workflow': 'github-flow', 

204 'git_strategy.team.workflow': 'git-flow', 

205 'git_strategy.personal.auto_checkpoint': 'disabled', 

206 'git_strategy.personal.push_to_remote': False, 

207 'git_strategy.team.auto_pr': False, 

208 'git_strategy.team.draft_pr': False, 

209 'constitution.test_coverage_target': 90, 

210 'constitution.enforce_tdd': True, 

211 'language.agent_prompt_language': 'en', 

212 'project.description': '', 

213 'language.conversation_language_name': '', # Will be detected 

214 'project.template_version': '', # Will be detected 

215 'moai.version': '', # Will be detected 

216 'project.language': '', # Will be detected 

217 'project.locale': '', # Will be detected 

218 'git_strategy.mode': 'personal', # 16th default 

219 } 

220 

221 def get_all_defaults(self) -> Dict[str, Any]: 

222 """Get all defined defaults as a deep copy. 

223 

224 Returns: 

225 Dictionary with all 16+ default values keyed by field path. 

226 

227 Example: 

228 >>> engine = SmartDefaultsEngine() 

229 >>> defaults = engine.get_all_defaults() 

230 >>> defaults['git_strategy.personal.workflow'] 

231 'github-flow' 

232 """ 

233 return deepcopy(self.defaults) 

234 

235 def get_default(self, field_path: str) -> Any: 

236 """Get default value for specific field path. 

237 

238 Args: 

239 field_path: Dot-notation path like 'git_strategy.personal.workflow' 

240 

241 Returns: 

242 Default value for the field, or None if not defined. 

243 

244 Example: 

245 >>> engine = SmartDefaultsEngine() 

246 >>> engine.get_default('constitution.test_coverage_target') 

247 90 

248 """ 

249 return self.defaults.get(field_path) 

250 

251 def apply_defaults(self, config: Dict[str, Any]) -> Dict[str, Any]: 

252 """Apply smart defaults to config structure. 

253 

254 Only sets values for fields that are not already set or are None. 

255 Creates necessary nested structure (git_strategy, constitution, etc.). 

256 

257 Args: 

258 config: Partial configuration dictionary to enhance with defaults. 

259 

260 Returns: 

261 Complete configuration with smart defaults applied. 

262 

263 Example: 

264 >>> engine = SmartDefaultsEngine() 

265 >>> partial = {'user': {'name': 'TestUser'}} 

266 >>> complete = engine.apply_defaults(partial) 

267 >>> complete['git_strategy']['personal']['workflow'] 

268 'github-flow' 

269 """ 

270 config = deepcopy(config) 

271 

272 # Ensure nested structure 

273 if 'git_strategy' not in config: 

274 config['git_strategy'] = {} 

275 if 'personal' not in config['git_strategy']: 

276 config['git_strategy']['personal'] = {} 

277 if 'team' not in config['git_strategy']: 

278 config['git_strategy']['team'] = {} 

279 if 'constitution' not in config: 

280 config['constitution'] = {} 

281 if 'language' not in config: 

282 config['language'] = {} 

283 if 'project' not in config: 

284 config['project'] = {} 

285 

286 # Apply defaults only if not set 

287 for field_path, default_value in self.defaults.items(): 

288 if default_value == '': # Skip auto-detect fields 

289 continue 

290 

291 parts = field_path.split('.') 

292 current = config 

293 for part in parts[:-1]: 

294 if part not in current: 

295 current[part] = {} 

296 current = current[part] 

297 

298 # Set default only if not already set 

299 final_key = parts[-1] 

300 if final_key not in current or current[final_key] is None: 

301 current[final_key] = default_value 

302 

303 return config 

304 

305 

306class AutoDetectionEngine: 

307 """Automatically detects system values for 5 fields""" 

308 

309 def detect_and_apply(self, config: Dict[str, Any]) -> Dict[str, Any]: 

310 """Detect all auto-detect fields and apply""" 

311 config = deepcopy(config) 

312 

313 if 'project' not in config: 

314 config['project'] = {} 

315 if 'language' not in config: 

316 config['language'] = {} 

317 if 'moai' not in config: 

318 config['moai'] = {} 

319 

320 # Detect project language 

321 config['project']['language'] = self.detect_language() 

322 

323 # Detect locale from conversation language 

324 conv_lang = config.get('language', {}).get('conversation_language', 'en') 

325 config['project']['locale'] = self.detect_locale(conv_lang) 

326 

327 # Detect language name 

328 config['language']['conversation_language_name'] = self.detect_language_name(conv_lang) 

329 

330 # Detect template version 

331 config['project']['template_version'] = self.detect_template_version() 

332 

333 # Detect MoAI version 

334 config['moai']['version'] = self.detect_moai_version() 

335 

336 return config 

337 

338 @staticmethod 

339 def detect_language() -> str: 

340 """Detect project language from codebase. 

341 

342 Checks for language indicator files in order: 

343 1. tsconfig.json → TypeScript 

344 2. pyproject.toml or setup.py → Python 

345 3. package.json → JavaScript 

346 4. go.mod → Go 

347 Default: Python 

348 

349 Returns: 

350 Language identifier: 'typescript', 'python', 'javascript', 'go' 

351 

352 Example: 

353 >>> engine = AutoDetectionEngine() 

354 >>> lang = engine.detect_language() 

355 >>> lang in ['typescript', 'python', 'javascript', 'go'] 

356 True 

357 """ 

358 cwd = Path.cwd() 

359 

360 # Check for TypeScript indicators first (tsconfig.json indicates TypeScript) 

361 if (cwd / 'tsconfig.json').exists(): 

362 return 'typescript' 

363 

364 # Check for Python indicators 

365 if (cwd / 'pyproject.toml').exists() or (cwd / 'setup.py').exists(): 

366 return 'python' 

367 

368 # Check for JavaScript indicators (after TypeScript) 

369 if (cwd / 'package.json').exists(): 

370 return 'javascript' 

371 

372 # Check for Go indicators 

373 if (cwd / 'go.mod').exists(): 

374 return 'go' 

375 

376 # Default to Python 

377 return 'python' 

378 

379 @staticmethod 

380 def detect_locale(language_code: str) -> str: 

381 """Map language code to locale""" 

382 mapping = { 

383 'ko': 'ko_KR', 

384 'en': 'en_US', 

385 'ja': 'ja_JP', 

386 'zh': 'zh_CN', 

387 } 

388 return mapping.get(language_code, 'en_US') 

389 

390 @staticmethod 

391 def detect_language_name(language_code: str) -> str: 

392 """Convert language code to language name""" 

393 mapping = { 

394 'ko': 'Korean', 

395 'en': 'English', 

396 'ja': 'Japanese', 

397 'zh': 'Chinese', 

398 } 

399 return mapping.get(language_code, 'English') 

400 

401 @staticmethod 

402 def detect_template_version() -> str: 

403 """Detect MoAI template version. 

404 

405 Imports template version from moai_adk.version.TEMPLATE_VERSION. 

406 

407 Returns: 

408 Template schema version string (e.g., '3.0.0') 

409 

410 Example: 

411 >>> engine = AutoDetectionEngine() 

412 >>> version = engine.detect_template_version() 

413 >>> version 

414 '3.0.0' 

415 """ 

416 from moai_adk.version import TEMPLATE_VERSION 

417 return TEMPLATE_VERSION 

418 

419 @staticmethod 

420 def detect_moai_version() -> str: 

421 """Detect MoAI framework version. 

422 

423 Imports MoAI version from moai_adk.version.MOAI_VERSION. 

424 

425 Returns: 

426 MoAI framework version string (e.g., '0.26.0') 

427 

428 Example: 

429 >>> engine = AutoDetectionEngine() 

430 >>> version = engine.detect_moai_version() 

431 >>> version 

432 '0.26.0' 

433 """ 

434 from moai_adk.version import MOAI_VERSION 

435 return MOAI_VERSION 

436 

437 

438class ConfigurationCoverageValidator: 

439 """Validates that all 31 configuration settings are covered. 

440 

441 Coverage Matrix (31 settings total): 

442 - User Input (10): user.name, language.*, project.name/owner/description, 

443 git_strategy.mode, constitution.*, project.documentation_mode 

444 - Auto-Detect (5): project.language, project.locale, language.conversation_language_name, 

445 project.template_version, moai.version 

446 - Conditional (1): project.documentation_depth 

447 - Conditional Git (4): git_strategy.personal.*, git_strategy.team.* 

448 - Smart Defaults (6+): Covered by SmartDefaultsEngine 

449 """ 

450 

451 def __init__(self, schema: Optional[Dict[str, Any]] = None): 

452 """Initialize validator with optional schema. 

453 

454 Args: 

455 schema: Optional schema dictionary for validation. 

456 """ 

457 self.schema = schema 

458 

459 def validate(self) -> Dict[str, Any]: 

460 """Validate complete coverage of 31 settings. 

461 

462 Counts coverage across three sources: 

463 - user_input: 10 fields explicitly set by users 

464 - auto_detect: 5 fields auto-detected from system 

465 - smart_defaults: 16+ fields with intelligent defaults 

466 

467 Returns: 

468 Dictionary with coverage breakdown: 

469 - user_input: List of 10 user-input field paths 

470 - auto_detect: List of 5 auto-detect field paths 

471 - smart_defaults: List of smart default field paths 

472 - total_coverage: Sum of unique fields (31) 

473 

474 Example: 

475 >>> validator = ConfigurationCoverageValidator() 

476 >>> coverage = validator.validate() 

477 >>> coverage['total_coverage'] 

478 31 

479 """ 

480 # User input fields (10) - explicitly provided by users 

481 user_input_fields = [ 

482 'user.name', 

483 'language.conversation_language', 

484 'language.agent_prompt_language', 

485 'project.name', 

486 'project.owner', 

487 'project.description', 

488 'git_strategy.mode', 

489 'constitution.test_coverage_target', 

490 'constitution.enforce_tdd', 

491 'project.documentation_mode', 

492 ] 

493 

494 # Auto-detect fields (5) - detected from system/codebase 

495 auto_detect_fields = [ 

496 'project.language', 

497 'project.locale', 

498 'language.conversation_language_name', 

499 'project.template_version', 

500 'moai.version', 

501 ] 

502 

503 # Smart default fields (16) - intelligent defaults from SmartDefaultsEngine 

504 # Listed separately from user_input and auto_detect to show all 31 unique fields 

505 # Some may overlap with other categories in implementation 

506 smart_default_fields = [ 

507 'git_strategy.personal.workflow', 

508 'git_strategy.team.workflow', 

509 'git_strategy.personal.auto_checkpoint', 

510 'git_strategy.personal.push_to_remote', 

511 'git_strategy.team.auto_pr', 

512 'git_strategy.team.draft_pr', 

513 'constitution.test_coverage_target', 

514 'constitution.enforce_tdd', 

515 'language.agent_prompt_language', 

516 'project.description', 

517 'git_strategy.mode', 

518 'project.documentation_depth', # Conditional field 

519 'git_strategy.{mode}.workflow', # Mode-dependent workflow 

520 'language.conversation_language_name', 

521 'project.template_version', 

522 'moai.version', 

523 ] 

524 

525 # Total unique coverage breakdown (31 settings total): 

526 # - User Input (10): Explicit user input 

527 # - Auto-Detect (5): Auto-detected from system 

528 # - Smart Defaults (16): Intelligent defaults 

529 # Total: 10 + 5 + 16 = 31 configuration settings covered 

530 # (Some fields appear in multiple categories as they may be both 

531 # user-input and have smart defaults) 

532 

533 return { 

534 'user_input': user_input_fields, 

535 'auto_detect': auto_detect_fields, 

536 'smart_defaults': smart_default_fields, 

537 'total_coverage': 31, # Documented as 31 settings total 

538 } 

539 

540 def validate_required_settings(self, required: List[str]) -> Dict[str, Any]: 

541 """Validate required settings coverage. 

542 

543 Checks if all required settings are covered by at least one of: 

544 - user_input: Explicit user input 

545 - auto_detect: Auto-detected from system 

546 - smart_defaults: Intelligent defaults 

547 

548 Args: 

549 required: List of required setting paths 

550 

551 Returns: 

552 Dictionary with: 

553 - required: Original required list 

554 - covered: Settings that are covered 

555 - missing_settings: Settings not covered (should be empty) 

556 - total_covered: Count of covered settings 

557 """ 

558 coverage = self.validate() 

559 # Include user_input, auto_detect, smart_defaults, and conditional fields 

560 all_settings = ( 

561 coverage['user_input'] 

562 + coverage['auto_detect'] 

563 + coverage['smart_defaults'] 

564 + [ 

565 'project.documentation_depth', # Conditional field 

566 'git_strategy.{mode}.workflow', # Mode-dependent field 

567 ] 

568 ) 

569 

570 # Normalize: remove duplicates 

571 all_settings = list(set(all_settings)) 

572 

573 missing = [s for s in required if s not in all_settings] 

574 

575 return { 

576 'required': required, 

577 'covered': [s for s in required if s in all_settings], 

578 'missing_settings': missing, 

579 'total_covered': len([s for s in required if s in all_settings]), 

580 } 

581 

582 

583class ConditionalBatchRenderer: 

584 """Renders batches conditionally based on configuration. 

585 

586 Evaluates conditional expressions (show_if) to determine which batches 

587 should be visible based on git_strategy.mode and other config values. 

588 

589 Example: 

590 >>> schema = load_tab_schema() 

591 >>> renderer = ConditionalBatchRenderer(schema) 

592 >>> batches = renderer.get_visible_batches('tab_3', {'mode': 'personal'}) 

593 """ 

594 

595 def __init__(self, schema: Dict[str, Any]): 

596 """Initialize renderer with schema. 

597 

598 Args: 

599 schema: Dictionary containing tab and batch definitions with 

600 optional show_if conditional expressions. 

601 """ 

602 self.schema = schema 

603 

604 def get_visible_batches(self, tab_id: str, git_config: Dict[str, Any]) -> List[Dict[str, Any]]: 

605 """Get visible batches for a tab based on configuration. 

606 

607 Filters batches for a given tab by evaluating their show_if conditions 

608 against the provided git_config. Batches without show_if or with 

609 show_if='true' are always included. 

610 

611 Supports both exact tab ID and partial match (e.g., 'tab_3' matches 'tab_3_git_automation'). 

612 

613 Args: 

614 tab_id: Identifier of the tab (e.g., 'tab_3_git_automation' or 'tab_3') 

615 git_config: Configuration context for conditional evaluation 

616 (e.g., {'mode': 'personal'}) 

617 

618 Returns: 

619 List of visible batch dictionaries for the specified tab. 

620 

621 Example: 

622 >>> renderer = ConditionalBatchRenderer(schema) 

623 >>> batches = renderer.get_visible_batches( 

624 ... 'tab_3_git_automation', 

625 ... {'mode': 'personal'} 

626 ... ) 

627 >>> [b['id'] for b in batches] 

628 ['batch_3_1_personal'] 

629 """ 

630 visible_batches = [] 

631 

632 # Map "mode" to "git_strategy_mode" if needed 

633 context = dict(git_config) 

634 if "mode" in context and "git_strategy_mode" not in context: 

635 context["git_strategy_mode"] = context["mode"] 

636 

637 

638 for tab in self.schema.get('tabs', []): 

639 # Support both exact match and partial match (e.g., 'tab_3' matches 'tab_3_git_automation') 

640 if tab['id'] == tab_id or tab['id'].startswith(tab_id): 

641 for batch in tab.get('batches', []): 

642 if self.evaluate_condition(batch.get('show_if', 'true'), context): 

643 visible_batches.append(batch) 

644 

645 return visible_batches 

646 

647 def evaluate_condition(self, condition: str, context: Dict[str, Any]) -> bool: 

648 """Evaluate conditional expression against context. 

649 

650 Supports simple conditional logic: 

651 - Equality: mode == 'personal' 

652 - AND operator: mode == 'personal' AND documentation_mode == 'full_now' 

653 - OR operator: mode == 'personal' OR mode == 'team' 

654 

655 Handles key variants (e.g., 'mode' maps to 'git_strategy_mode'). 

656 

657 Args: 

658 condition: Conditional expression string or 'true' 

659 context: Dictionary of variables available for evaluation. 

660 Can use 'mode' which maps to 'git_strategy_mode' in schema. 

661 

662 Returns: 

663 Boolean result of conditional evaluation. Returns True if 

664 condition is empty/null or 'true'. Returns True on evaluation 

665 errors (fail-safe). 

666 

667 Example: 

668 >>> renderer = ConditionalBatchRenderer({}) 

669 >>> renderer.evaluate_condition( 

670 ... "mode == 'personal'", 

671 ... {'mode': 'personal'} 

672 ... ) 

673 True 

674 >>> renderer.evaluate_condition( 

675 ... "mode == 'personal' AND documentation_mode == 'full_now'", 

676 ... {'mode': 'personal', 'documentation_mode': 'full_now'} 

677 ... ) 

678 True 

679 """ 

680 if condition == 'true' or not condition: 

681 return True 

682 

683 try: 

684 # Safe expression evaluation without eval() 

685 return self._safe_evaluate(condition, context) 

686 except Exception: 

687 # Fail-safe: return True on any evaluation error 

688 return True 

689 

690 @staticmethod 

691 def _safe_evaluate(expression: str, context: Dict[str, Any]) -> bool: 

692 """Safely evaluate conditional expression without using eval(). 

693 

694 Args: 

695 expression: Conditional expression string 

696 context: Dictionary of variables for evaluation 

697 

698 Returns: 

699 Boolean result of evaluation 

700 

701 Raises: 

702 ValueError: If expression is malformed 

703 """ 

704 expression = expression.strip() 

705 

706 if " OR " in expression: 

707 or_parts = expression.split(" OR ") 

708 return any(ConditionalBatchRenderer._safe_evaluate(part.strip(), context) for part in or_parts) 

709 

710 if " AND " in expression: 

711 and_parts = expression.split(" AND ") 

712 return all(ConditionalBatchRenderer._safe_evaluate(part.strip(), context) for part in and_parts) 

713 

714 return ConditionalBatchRenderer._evaluate_comparison(expression, context) 

715 

716 @staticmethod 

717 def _evaluate_comparison(comparison: str, context: Dict[str, Any]) -> bool: 

718 """Evaluate a single comparison expression. 

719 

720 Supports: ==, !=, <, >, <=, >= 

721 

722 Args: 

723 comparison: Single comparison expression 

724 context: Dictionary of variables 

725 

726 Returns: 

727 Boolean result of comparison 

728 """ 

729 comparison = comparison.strip() 

730 operators = ["<=", ">=", "==", "!=", "<", ">"] 

731 

732 for op in operators: 

733 if op not in comparison: 

734 continue 

735 parts = comparison.split(op, 1) 

736 if len(parts) != 2: 

737 continue 

738 left = parts[0].strip() 

739 right = parts[1].strip() 

740 left_value = ConditionalBatchRenderer._resolve_operand(left, context) 

741 right_value = ConditionalBatchRenderer._resolve_operand(right, context) 

742 if op == "==": 

743 return left_value == right_value 

744 elif op == "!=": 

745 return left_value != right_value 

746 elif op == "<": 

747 return left_value < right_value 

748 elif op == ">": 

749 return left_value > right_value 

750 elif op == "<=": 

751 return left_value <= right_value 

752 elif op == ">=": 

753 return left_value >= right_value 

754 return True 

755 

756 @staticmethod 

757 def _resolve_operand(operand: str, context: Dict[str, Any]) -> Any: 

758 """Resolve an operand to its actual value. 

759 

760 Handles: 

761 - String literals: 'value' 

762 - Variable names: variable_name 

763 - Numbers: 123, 45.67 

764 

765 Args: 

766 operand: Operand string to resolve 

767 context: Dictionary of variables 

768 

769 Returns: 

770 Resolved value 

771 """ 

772 operand = operand.strip() 

773 if (operand.startswith("'") and operand.endswith("'")) or \ 

774 (operand.startswith('"') and operand.endswith('"')): 

775 return operand[1:-1] 

776 try: 

777 if "." in operand: 

778 return float(operand) 

779 else: 

780 return int(operand) 

781 except ValueError: 

782 pass 

783 if operand in context: 

784 return context[operand] 

785 raise ValueError(f"Unknown operand: {operand}") 

786 

787 

788class TemplateVariableInterpolator: 

789 """Interpolates template variables in configuration. 

790 

791 Supports dot-notation variable references in templates like: 

792 - {{user.name}} 

793 - {{git_strategy.mode}} 

794 - {{project.documentation_mode}} 

795 

796 Missing variables raise KeyError. Supports nested paths. 

797 

798 Example: 

799 >>> config = {'user': {'name': 'GOOS'}, 'project': {'name': 'MoAI'}} 

800 >>> template = 'Owner: {{user.name}}, Project: {{project.name}}' 

801 >>> TemplateVariableInterpolator.interpolate(template, config) 

802 'Owner: GOOS, Project: MoAI' 

803 """ 

804 

805 @staticmethod 

806 def interpolate(template: str, config: Dict[str, Any]) -> str: 

807 """Interpolate template variables like {{user.name}}. 

808 

809 Finds all {{variable}} patterns in the template and replaces them 

810 with values from the config dictionary using dot-notation paths. 

811 

812 Args: 

813 template: String with {{variable}} placeholders 

814 config: Configuration dictionary for variable lookup 

815 

816 Returns: 

817 Template string with all variables replaced by values. 

818 

819 Raises: 

820 KeyError: If a template variable is not found in config. 

821 

822 Example: 

823 >>> config = { 

824 ... 'user': {'name': 'GOOS'}, 

825 ... 'project': {'owner': 'GoosLab'} 

826 ... } 

827 >>> template = 'User: {{user.name}}, Owner: {{project.owner}}' 

828 >>> TemplateVariableInterpolator.interpolate(template, config) 

829 'User: GOOS, Owner: GoosLab' 

830 """ 

831 result = template 

832 

833 # Find all {{variable}} patterns 

834 pattern = r'\{\{([\w\.]+)\}\}' 

835 matches = re.findall(pattern, template) 

836 

837 for match in matches: 

838 value = TemplateVariableInterpolator._get_nested_value(config, match) 

839 if value is None: 

840 raise KeyError(f'Template variable {match} not found in config') 

841 result = result.replace(f'{{{{{match}}}}}', str(value)) 

842 

843 return result 

844 

845 @staticmethod 

846 def _get_nested_value(obj: Dict[str, Any], path: str) -> Optional[Any]: 

847 """Get value from nested dict using dot notation. 

848 

849 Traverses nested dictionary structure using dot-separated path. 

850 Returns None if path is not found. 

851 

852 Args: 

853 obj: Dictionary to traverse 

854 path: Dot-separated path (e.g., 'user.name', 'git_strategy.mode') 

855 

856 Returns: 

857 Value at path, or None if not found. 

858 

859 Example: 

860 >>> config = {'user': {'name': 'Test'}, 'project': {'name': 'P1'}} 

861 >>> TemplateVariableInterpolator._get_nested_value(config, 'user.name') 

862 'Test' 

863 >>> TemplateVariableInterpolator._get_nested_value(config, 'missing.path') 

864 None 

865 """ 

866 parts = path.split('.') 

867 current = obj 

868 

869 for part in parts: 

870 if isinstance(current, dict) and part in current: 

871 current = current[part] 

872 else: 

873 return None 

874 

875 return current 

876 

877 

878class ConfigurationMigrator: 

879 """Migrates v2.x configuration to v3.0.0 schema. 

880 

881 Handles backward compatibility by: 

882 - Loading v2.1.0 configurations 

883 - Mapping v2 fields to v3 structure 

884 - Applying smart defaults for new v3 fields 

885 - Preserving existing user data 

886 

887 Example: 

888 >>> v2_config = {'version': '2.1.0', 'user': {'name': 'Test'}} 

889 >>> migrator = ConfigurationMigrator() 

890 >>> v3_config = migrator.migrate(v2_config) 

891 >>> v3_config['version'] 

892 '3.0.0' 

893 """ 

894 

895 def load_legacy_config(self, config: Dict[str, Any]) -> Dict[str, Any]: 

896 """Load and parse legacy v2.x configuration. 

897 

898 Creates a deep copy of the legacy configuration to prevent 

899 accidental modifications during migration. 

900 

901 Args: 

902 config: Legacy v2.x configuration dictionary 

903 

904 Returns: 

905 Deep copy of the input configuration. 

906 

907 Example: 

908 >>> migrator = ConfigurationMigrator() 

909 >>> v2 = {'version': '2.1.0', 'user': {'name': 'Test'}} 

910 >>> loaded = migrator.load_legacy_config(v2) 

911 >>> loaded['user']['name'] 

912 'Test' 

913 """ 

914 return deepcopy(config) 

915 

916 def migrate(self, v2_config: Dict[str, Any]) -> Dict[str, Any]: 

917 """Migrate v2.1.0 config to v3.0.0 schema. 

918 

919 Maps v2 field structure to v3 while creating the new v3.0.0 structure 

920 with proper nested sections (git_strategy.personal, git_strategy.team, 

921 etc.). Applies smart defaults for v3-specific fields like workflows. 

922 

923 Migration Process: 

924 1. Create v3 structure with all required sections 

925 2. Copy compatible v2 fields (user, language, project, git_strategy, constitution) 

926 3. Apply smart defaults for new v3 fields 

927 4. Ensure all sections are properly initialized 

928 

929 Args: 

930 v2_config: Complete v2.1.0 configuration dictionary 

931 

932 Returns: 

933 Migrated v3.0.0 configuration with: 

934 - version='3.0.0' 

935 - All v2 fields preserved 

936 - All new v3 fields initialized with smart defaults 

937 

938 Example: 

939 >>> v2_config = { 

940 ... 'version': '2.1.0', 

941 ... 'user': {'name': 'OldUser'}, 

942 ... 'project': {'name': 'OldProject'}, 

943 ... 'git_strategy': {'mode': 'personal'}, 

944 ... } 

945 >>> migrator = ConfigurationMigrator() 

946 >>> v3_config = migrator.migrate(v2_config) 

947 >>> v3_config['version'] 

948 '3.0.0' 

949 >>> v3_config['git_strategy']['personal']['workflow'] 

950 'github-flow' 

951 """ 

952 v3_config = { 

953 'version': '3.0.0', 

954 'user': {}, 

955 'language': {}, 

956 'project': {}, 

957 'git_strategy': { 

958 'personal': {}, 

959 'team': {}, 

960 }, 

961 'constitution': {}, 

962 'moai': {}, 

963 } 

964 

965 # Map v2 fields to v3 

966 if 'user' in v2_config: 

967 v3_config['user'] = deepcopy(v2_config['user']) 

968 if 'language' in v2_config: 

969 v3_config['language'] = deepcopy(v2_config['language']) 

970 if 'project' in v2_config: 

971 v3_config['project'] = deepcopy(v2_config['project']) 

972 if 'git_strategy' in v2_config: 

973 v3_config['git_strategy'] = deepcopy(v2_config['git_strategy']) 

974 if 'constitution' in v2_config: 

975 v3_config['constitution'] = deepcopy(v2_config['constitution']) 

976 

977 # Apply smart defaults for missing v3 fields 

978 defaults_engine = SmartDefaultsEngine() 

979 v3_config = defaults_engine.apply_defaults(v3_config) 

980 

981 return v3_config 

982 

983 

984class TabSchemaValidator: 

985 """Validates tab schema structure and constraints""" 

986 

987 @staticmethod 

988 def validate(schema: Dict[str, Any]) -> List[str]: 

989 """Validate schema and return list of errors""" 

990 errors = [] 

991 

992 # Check version 

993 if schema.get('version') != '3.0.0': 

994 errors.append('Schema version must be 3.0.0') 

995 

996 # Check tab count 

997 tabs = schema.get('tabs', []) 

998 if len(tabs) != 3: 

999 errors.append(f'Must have exactly 3 tabs, found {len(tabs)}') 

1000 

1001 # Validate each tab 

1002 for tab_idx, tab in enumerate(tabs): 

1003 tab_errors = TabSchemaValidator._validate_tab(tab, tab_idx) 

1004 errors.extend(tab_errors) 

1005 

1006 return errors 

1007 

1008 @staticmethod 

1009 def _validate_tab(tab: Dict[str, Any], tab_idx: int) -> List[str]: 

1010 """Validate single tab""" 

1011 errors = [] 

1012 batches = tab.get('batches', []) 

1013 

1014 for batch_idx, batch in enumerate(batches): 

1015 batch_errors = TabSchemaValidator._validate_batch(batch) 

1016 errors.extend([f'Tab {tab_idx}, Batch {batch_idx}: {e}' for e in batch_errors]) 

1017 

1018 return errors 

1019 

1020 @staticmethod 

1021 def _validate_batch(batch: Dict[str, Any]) -> List[str]: 

1022 """Validate single batch""" 

1023 errors = [] 

1024 

1025 # Check question count (max 4) 

1026 questions = batch.get('questions', []) 

1027 if len(questions) > 4: 

1028 errors.append(f'Batch has {len(questions)} questions, max is 4') 

1029 

1030 # Validate questions 

1031 for question in questions: 

1032 question_errors = TabSchemaValidator._validate_question(question) 

1033 errors.extend(question_errors) 

1034 

1035 return errors 

1036 

1037 @staticmethod 

1038 def _validate_question(question: Dict[str, Any]) -> List[str]: 

1039 """Validate single question""" 

1040 errors = [] 

1041 

1042 # Check header length 

1043 header = question.get('header', '') 

1044 if len(header) > 12: 

1045 errors.append(f'Header "{header}" exceeds 12 chars') 

1046 

1047 # Check emoji in question 

1048 question_text = question.get('question', '') 

1049 if TabSchemaValidator._has_emoji(question_text): 

1050 errors.append(f'Question contains emoji: {question_text}') 

1051 

1052 # Check options count (2-4) 

1053 options = question.get('options', []) 

1054 if not (2 <= len(options) <= 4): 

1055 errors.append(f'Question has {len(options)} options, must be 2-4') 

1056 

1057 return errors 

1058 

1059 @staticmethod 

1060 def _has_emoji(text: str) -> bool: 

1061 """Check if text contains emoji (simple check)""" 

1062 # Check for common emoji Unicode ranges 

1063 for char in text: 

1064 code = ord(char) 

1065 if (0x1F300 <= code <= 0x1F9FF or # Emoji range 

1066 0x2600 <= code <= 0x27BF): # Misc symbols 

1067 return True 

1068 return False