""" Schema Refiner for Phase 2: Schema Refinement Tools Automatically refines rigid schemas by applying loosening rules and fixes. """ from pathlib import Path from typing import Dict, Any, List, Optional, Tuple import json import copy from dataclasses import dataclass, field from .analyzer import SchemaAnalyzer, SchemaIssue, IssueType, IssueSeverity @dataclass class RefinementAction: """Represents a refinement action taken on the schema.""" issue_type: IssueType path: str description: str old_value: Any = None new_value: Any = None @dataclass class RefinementResult: """Results of schema refinement.""" success: bool actions_taken: List[RefinementAction] = field(default_factory=list) refined_schema: Optional[Dict[str, Any]] = None error_message: Optional[str] = None class SchemaRefiner: """Refines rigid schemas by applying loosening rules.""" def __init__(self): """Initialize the schema refiner.""" self.analyzer = SchemaAnalyzer() def _navigate_to_path(self, schema: Dict[str, Any], path: str) -> Optional[Tuple[Dict[str, Any], str]]: """ Navigate to a path in the schema, handling nested 'properties' objects. Returns (parent_object, property_name) or None if path doesn't exist. """ path_parts = path.split('.') obj = schema # Navigate through all but the last part for i, part in enumerate(path_parts[:-1]): # Try direct access first if part in obj: obj = obj[part] # If not found and obj has 'properties', try there elif isinstance(obj, dict) and "properties" in obj and part in obj["properties"]: obj = obj["properties"][part] else: return None # For the final part, check if we need to descend into 'properties' prop_name = path_parts[-1] if prop_name in obj: return (obj, prop_name) elif isinstance(obj, dict) and "properties" in obj and prop_name in obj["properties"]: return (obj["properties"], prop_name) else: return None def refine_schema_interactive( self, schema: Dict[str, Any], loosen_counts: bool = True, migrate_deprecated: bool = False, round_numbers: bool = True ) -> RefinementResult: """ Refine a schema interactively, prompting for each fix. Args: schema: The JSON schema to refine loosen_counts: Enable fixes for exact counts migrate_deprecated: Enable migration of deprecated extensions round_numbers: Enable rounding of overly specific numbers Returns: RefinementResult with actions taken and refined schema """ result = RefinementResult(success=False) try: # Analyze the schema first analysis = self.analyzer.analyze_schema(schema) print(f"\nFound {len(analysis.issues)} issue(s) to review\n") # Deep copy to avoid modifying original refined = copy.deepcopy(schema) # Process each issue interactively for i, issue in enumerate(analysis.issues, 1): print(f"Issue {i}/{len(analysis.issues)}") print(f" Type: {issue.issue_type.value}") print(f" Path: {issue.path}") print(f" {issue.message}") print(f" Suggestion: {issue.suggestion}") if issue.current_value is not None: print(f" Current: {json.dumps(issue.current_value)}") if issue.suggested_value is not None: print(f" Suggested: {json.dumps(issue.suggested_value)}") # Ask user if they want to apply the fix response = input("\nApply this fix? [y/N/q]: ").strip().lower() if response == 'q': print("Refinement cancelled by user") result.success = False return result elif response == 'y': action = None if loosen_counts and issue.issue_type == IssueType.EXACT_COUNT: action = self._fix_exact_count(refined, issue) elif round_numbers and issue.issue_type == IssueType.OVERLY_SPECIFIC: action = self._fix_overly_specific(refined, issue) elif loosen_counts and issue.issue_type == IssueType.NO_FLEXIBILITY: action = self._fix_no_flexibility(refined, issue) elif migrate_deprecated and issue.issue_type == IssueType.DEPRECATED_EXTENSIONS: action = self._fix_deprecated_extension(refined, issue) if action: result.actions_taken.append(action) print(f" ✓ Applied") else: print(f" ✗ Could not apply fix") else: print(f" - Skipped") print() result.refined_schema = refined result.success = True except Exception as e: result.error_message = str(e) return result def refine_schema( self, schema: Dict[str, Any], loosen_counts: bool = True, migrate_deprecated: bool = False, round_numbers: bool = True ) -> RefinementResult: """ Refine a schema by applying fixes for detected issues. Args: schema: The JSON schema to refine loosen_counts: Apply fixes for exact counts migrate_deprecated: Migrate deprecated extensions round_numbers: Round overly specific numbers Returns: RefinementResult with actions taken and refined schema """ result = RefinementResult(success=False) try: # Analyze the schema first analysis = self.analyzer.analyze_schema(schema) # Deep copy to avoid modifying original refined = copy.deepcopy(schema) # Apply fixes based on issues found for issue in analysis.issues: action = None if loosen_counts and issue.issue_type == IssueType.EXACT_COUNT: action = self._fix_exact_count(refined, issue) elif round_numbers and issue.issue_type == IssueType.OVERLY_SPECIFIC: action = self._fix_overly_specific(refined, issue) elif loosen_counts and issue.issue_type == IssueType.NO_FLEXIBILITY: action = self._fix_no_flexibility(refined, issue) elif migrate_deprecated and issue.issue_type == IssueType.DEPRECATED_EXTENSIONS: action = self._fix_deprecated_extension(refined, issue) if action: result.actions_taken.append(action) result.refined_schema = refined result.success = True except Exception as e: result.error_message = str(e) return result def _fix_exact_count(self, schema: Dict[str, Any], issue: SchemaIssue) -> Optional[RefinementAction]: """Fix exact count constraints by converting to ranges.""" nav_result = self._navigate_to_path(schema, issue.path) if not nav_result: return None obj, prop_name = nav_result prop_def = obj[prop_name] old_value = copy.deepcopy(prop_def) # Check if it's an array with exact minItems/maxItems if isinstance(prop_def, dict) and prop_def.get("type") == "array": min_items = prop_def.get("minItems") max_items = prop_def.get("maxItems") if min_items is not None and max_items is not None and min_items == max_items: # Apply suggested loosening new_min = max(0, min_items - 2) new_max = min_items + 5 prop_def["minItems"] = new_min prop_def["maxItems"] = new_max return RefinementAction( issue_type=IssueType.EXACT_COUNT, path=issue.path, description=f"Loosened array count from exactly {min_items} to range {new_min}-{new_max}", old_value={"minItems": min_items, "maxItems": max_items}, new_value={"minItems": new_min, "maxItems": new_max} ) # Check if it's a const value if isinstance(prop_def, dict) and "const" in prop_def: const_value = prop_def["const"] del prop_def["const"] # If it's a number, convert to a range if isinstance(const_value, int): prop_def["minimum"] = const_value - 1 prop_def["maximum"] = const_value + 1 return RefinementAction( issue_type=IssueType.EXACT_COUNT, path=issue.path, description=f"Converted const {const_value} to range {const_value-1}-{const_value+1}", old_value=const_value, new_value={"minimum": const_value - 1, "maximum": const_value + 1} ) else: # For non-numeric constants, just remove the constraint return RefinementAction( issue_type=IssueType.EXACT_COUNT, path=issue.path, description=f"Removed const constraint: {const_value}", old_value=const_value, new_value=None ) return None def _fix_overly_specific(self, schema: Dict[str, Any], issue: SchemaIssue) -> Optional[RefinementAction]: """Fix overly specific number constraints by rounding.""" if issue.suggested_value is None: return None nav_result = self._navigate_to_path(schema, issue.path) if not nav_result: return None obj, prop_name = nav_result prop_def = obj[prop_name] # Round the minItems value if isinstance(prop_def, dict) and "minItems" in prop_def: old_value = prop_def["minItems"] new_value = issue.suggested_value prop_def["minItems"] = new_value return RefinementAction( issue_type=IssueType.OVERLY_SPECIFIC, path=issue.path, description=f"Rounded minItems from {old_value} to {new_value}", old_value=old_value, new_value=new_value ) return None def _fix_no_flexibility(self, schema: Dict[str, Any], issue: SchemaIssue) -> Optional[RefinementAction]: """Fix narrow ranges by widening them.""" nav_result = self._navigate_to_path(schema, issue.path) if not nav_result: return None obj, prop_name = nav_result prop_def = obj[prop_name] if isinstance(prop_def, dict) and "minimum" in prop_def and "maximum" in prop_def: old_min = prop_def["minimum"] old_max = prop_def["maximum"] range_size = old_max - old_min # Widen the range new_min = old_min - 5 new_max = old_max + 5 prop_def["minimum"] = new_min prop_def["maximum"] = new_max return RefinementAction( issue_type=IssueType.NO_FLEXIBILITY, path=issue.path, description=f"Widened range from {old_min}-{old_max} to {new_min}-{new_max}", old_value={"minimum": old_min, "maximum": old_max}, new_value={"minimum": new_min, "maximum": new_max} ) return None def _fix_deprecated_extension(self, schema: Dict[str, Any], issue: SchemaIssue) -> Optional[RefinementAction]: """Remove deprecated extension (migration requires manual work).""" # For now, just document that manual migration is needed # Full migration would require understanding the old format deprecated_key = issue.path if deprecated_key in schema: old_value = schema[deprecated_key] # Don't actually remove it automatically - too risky return RefinementAction( issue_type=IssueType.DEPRECATED_EXTENSIONS, path=issue.path, description=f"Detected deprecated extension (manual migration recommended)", old_value=old_value, new_value=None ) return None def refine_schema_file( self, input_path: Path, output_path: Optional[Path] = None, loosen_counts: bool = True, migrate_deprecated: bool = False, round_numbers: bool = True ) -> RefinementResult: """ Refine a schema file. Args: input_path: Path to input schema file output_path: Path to output file (if None, overwrites input) loosen_counts: Apply fixes for exact counts migrate_deprecated: Migrate deprecated extensions round_numbers: Round overly specific numbers Returns: RefinementResult """ with open(input_path) as f: schema = json.load(f) result = self.refine_schema( schema, loosen_counts=loosen_counts, migrate_deprecated=migrate_deprecated, round_numbers=round_numbers ) if result.success and result.refined_schema: output = output_path or input_path with open(output, 'w') as f: json.dump(result.refined_schema, f, indent=2) return result def format_refinement_report(self, result: RefinementResult) -> str: """ Format refinement results as a human-readable report. Args: result: Refinement results Returns: Formatted report string """ lines = [] # Header lines.append("=" * 70) lines.append("Schema Refinement Report") lines.append("=" * 70) lines.append("") if not result.success: lines.append(f"❌ Refinement failed: {result.error_message}") return "\n".join(lines) # Summary action_count = len(result.actions_taken) if action_count == 0: lines.append("✅ No refinements needed - schema is already flexible") else: lines.append(f"✅ Applied {action_count} refinement(s)") lines.append("") # List actions if result.actions_taken: lines.append("Actions Taken:") lines.append("-" * 70) for i, action in enumerate(result.actions_taken, 1): lines.append(f"{i}. {action.description}") lines.append(f" Path: {action.path}") if action.old_value is not None: lines.append(f" Before: {json.dumps(action.old_value)}") if action.new_value is not None: lines.append(f" After: {json.dumps(action.new_value)}") lines.append("") return "\n".join(lines) def refine_schema_cli( schema_path: str, output: Optional[str] = None, loosen_counts: bool = True, migrate_deprecated: bool = False, round_numbers: bool = True, dry_run: bool = False, interactive: bool = False ) -> int: """ CLI entry point for schema refinement. Args: schema_path: Path to schema file output: Output path (None = overwrite input) loosen_counts: Apply count loosening fixes migrate_deprecated: Migrate deprecated extensions round_numbers: Round overly specific numbers dry_run: Show changes without applying interactive: Prompt for each fix Returns: Exit code (0 = success, 1 = no changes needed, 2 = error) """ refiner = SchemaRefiner() try: input_path = Path(schema_path) output_path = Path(output) if output else None # Load schema with open(input_path) as f: schema = json.load(f) if interactive: # Interactive mode - prompt for each fix print(f"Refining schema: {schema_path}") result = refiner.refine_schema_interactive( schema, loosen_counts=loosen_counts, migrate_deprecated=migrate_deprecated, round_numbers=round_numbers ) if result.success and result.refined_schema and not dry_run: # Write the refined schema output = output_path or input_path with open(output, 'w') as f: json.dump(result.refined_schema, f, indent=2) print(f"\nRefined schema written to: {output}") elif dry_run: # Just analyze and show what would be done result = refiner.refine_schema( schema, loosen_counts=loosen_counts, migrate_deprecated=migrate_deprecated, round_numbers=round_numbers ) print("DRY RUN - No changes will be made") print() else: result = refiner.refine_schema_file( input_path, output_path, loosen_counts=loosen_counts, migrate_deprecated=migrate_deprecated, round_numbers=round_numbers ) # Only print full report if not in interactive mode (user already saw changes) if not interactive: report = refiner.format_refinement_report(result) print(report) elif result.success: # Just print summary for interactive mode print(f"\n{'='*70}") print(f"Refinement complete: {len(result.actions_taken)} change(s) applied") print(f"{'='*70}") if result.success and len(result.actions_taken) > 0: return 0 # Success with changes elif result.success: return 1 # Success but no changes needed else: return 2 # Error except FileNotFoundError: print(f"Error: Schema file not found: {schema_path}") return 2 except json.JSONDecodeError as e: print(f"Error: Invalid JSON in schema file: {e}") return 2 except Exception as e: print(f"Error: {e}") return 2