#!/usr/bin/env python3 """ API Linter - Analyzes OpenAPI/Swagger specifications for REST conventions and best practices. This script validates API designs against established conventions including: - Resource naming conventions (kebab-case resources, camelCase fields) - HTTP method usage patterns - URL structure consistency - Error response format standards - Documentation completeness - Pagination patterns - Versioning compliance Supports both OpenAPI JSON specifications and raw endpoint definition JSON. """ import argparse import json import re import sys from typing import Any, Dict, List, Tuple, Optional, Set from urllib.parse import urlparse from dataclasses import dataclass, field @dataclass class LintIssue: """Represents a linting issue found in the API specification.""" severity: str # 'error', 'warning', 'info' category: str message: str path: str suggestion: str = "" line_number: Optional[int] = None @dataclass class LintReport: """Complete linting report with issues and statistics.""" issues: List[LintIssue] = field(default_factory=list) total_endpoints: int = 0 endpoints_with_issues: int = 0 score: float = 0.0 def add_issue(self, issue: LintIssue) -> None: """Add an issue to the report.""" self.issues.append(issue) def get_issues_by_severity(self) -> Dict[str, List[LintIssue]]: """Group issues by severity level.""" grouped = {'error': [], 'warning': [], 'info': []} for issue in self.issues: if issue.severity in grouped: grouped[issue.severity].append(issue) return grouped def calculate_score(self) -> float: """Calculate overall API quality score (0-100).""" if self.total_endpoints == 0: return 100.0 error_penalty = len([i for i in self.issues if i.severity == 'error']) * 10 warning_penalty = len([i for i in self.issues if i.severity == 'warning']) * 3 info_penalty = len([i for i in self.issues if i.severity == 'info']) * 1 total_penalty = error_penalty + warning_penalty + info_penalty base_score = 100.0 # Penalty per endpoint to normalize across API sizes penalty_per_endpoint = total_penalty / self.total_endpoints if self.total_endpoints > 0 else total_penalty self.score = max(0.0, base_score - penalty_per_endpoint) return self.score class APILinter: """Main API linting engine.""" def __init__(self): self.report = LintReport() self.openapi_spec: Optional[Dict] = None self.raw_endpoints: Optional[Dict] = None # Regex patterns for naming conventions self.kebab_case_pattern = re.compile(r'^[a-z]+(?:-[a-z0-9]+)*$') self.camel_case_pattern = re.compile(r'^[a-z][a-zA-Z0-9]*$') self.snake_case_pattern = re.compile(r'^[a-z]+(?:_[a-z0-9]+)*$') self.pascal_case_pattern = re.compile(r'^[A-Z][a-zA-Z0-9]*$') # Standard HTTP methods self.http_methods = {'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'} # Standard HTTP status codes by method self.standard_status_codes = { 'GET': {200, 304, 404}, 'POST': {200, 201, 400, 409, 422}, 'PUT': {200, 204, 400, 404, 409}, 'PATCH': {200, 204, 400, 404, 409}, 'DELETE': {200, 204, 404}, 'HEAD': {200, 404}, 'OPTIONS': {200} } # Common error status codes self.common_error_codes = {400, 401, 403, 404, 405, 409, 422, 429, 500, 502, 503} def lint_openapi_spec(self, spec: Dict[str, Any]) -> LintReport: """Lint an OpenAPI/Swagger specification.""" self.openapi_spec = spec self.report = LintReport() # Basic structure validation self._validate_openapi_structure() # Info section validation self._validate_info_section() # Server section validation self._validate_servers_section() # Paths validation (main linting logic) self._validate_paths_section() # Components validation self._validate_components_section() # Security validation self._validate_security_section() # Calculate final score self.report.calculate_score() return self.report def lint_raw_endpoints(self, endpoints: Dict[str, Any]) -> LintReport: """Lint raw endpoint definitions.""" self.raw_endpoints = endpoints self.report = LintReport() # Validate raw endpoint structure self._validate_raw_endpoint_structure() # Lint each endpoint for endpoint_path, endpoint_data in endpoints.get('endpoints', {}).items(): self._lint_raw_endpoint(endpoint_path, endpoint_data) self.report.calculate_score() return self.report def _validate_openapi_structure(self) -> None: """Validate basic OpenAPI document structure.""" required_fields = ['openapi', 'info', 'paths'] for field in required_fields: if field not in self.openapi_spec: self.report.add_issue(LintIssue( severity='error', category='structure', message=f"Missing required field: {field}", path=f"/{field}", suggestion=f"Add the '{field}' field to the root of your OpenAPI specification" )) def _validate_info_section(self) -> None: """Validate the info section of OpenAPI spec.""" if 'info' not in self.openapi_spec: return info = self.openapi_spec['info'] required_info_fields = ['title', 'version'] recommended_info_fields = ['description', 'contact'] for field in required_info_fields: if field not in info: self.report.add_issue(LintIssue( severity='error', category='documentation', message=f"Missing required info field: {field}", path=f"/info/{field}", suggestion=f"Add a '{field}' field to the info section" )) for field in recommended_info_fields: if field not in info: self.report.add_issue(LintIssue( severity='warning', category='documentation', message=f"Missing recommended info field: {field}", path=f"/info/{field}", suggestion=f"Consider adding a '{field}' field to improve API documentation" )) # Validate version format if 'version' in info: version = info['version'] if not re.match(r'^\d+\.\d+(\.\d+)?(-\w+)?$', version): self.report.add_issue(LintIssue( severity='warning', category='versioning', message=f"Version format '{version}' doesn't follow semantic versioning", path="/info/version", suggestion="Use semantic versioning format (e.g., '1.0.0', '2.1.3-beta')" )) def _validate_servers_section(self) -> None: """Validate the servers section.""" if 'servers' not in self.openapi_spec: self.report.add_issue(LintIssue( severity='warning', category='configuration', message="Missing servers section", path="/servers", suggestion="Add a servers section to specify API base URLs" )) return servers = self.openapi_spec['servers'] if not isinstance(servers, list) or len(servers) == 0: self.report.add_issue(LintIssue( severity='warning', category='configuration', message="Empty servers section", path="/servers", suggestion="Add at least one server URL" )) def _validate_paths_section(self) -> None: """Validate all API paths and operations.""" if 'paths' not in self.openapi_spec: return paths = self.openapi_spec['paths'] if not paths: self.report.add_issue(LintIssue( severity='error', category='structure', message="No paths defined in API specification", path="/paths", suggestion="Define at least one API endpoint" )) return self.report.total_endpoints = sum( len([method for method in path_obj.keys() if method.upper() in self.http_methods]) for path_obj in paths.values() if isinstance(path_obj, dict) ) endpoints_with_issues = set() for path, path_obj in paths.items(): if not isinstance(path_obj, dict): continue # Validate path structure path_issues = self._validate_path_structure(path) if path_issues: endpoints_with_issues.add(path) # Validate each operation in the path for method, operation in path_obj.items(): if method.upper() not in self.http_methods: continue operation_issues = self._validate_operation(path, method.upper(), operation) if operation_issues: endpoints_with_issues.add(path) self.report.endpoints_with_issues = len(endpoints_with_issues) def _validate_path_structure(self, path: str) -> bool: """Validate REST path structure and naming conventions.""" has_issues = False # Check if path starts with slash if not path.startswith('/'): self.report.add_issue(LintIssue( severity='error', category='url_structure', message=f"Path must start with '/' character: {path}", path=f"/paths/{path}", suggestion=f"Change '{path}' to '/{path.lstrip('/')}'" )) has_issues = True # Split path into segments segments = [seg for seg in path.split('/') if seg] # Check for empty segments (double slashes) if '//' in path: self.report.add_issue(LintIssue( severity='error', category='url_structure', message=f"Path contains empty segments: {path}", path=f"/paths/{path}", suggestion="Remove double slashes from the path" )) has_issues = True # Validate each segment for i, segment in enumerate(segments): # Skip parameter segments if segment.startswith('{') and segment.endswith('}'): # Validate parameter naming param_name = segment[1:-1] if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Path parameter '{param_name}' should use camelCase or kebab-case", path=f"/paths/{path}", suggestion=f"Use camelCase (e.g., 'userId') or kebab-case (e.g., 'user-id')" )) has_issues = True continue # Check for resource naming conventions if not self.kebab_case_pattern.match(segment): # Allow version segments like 'v1', 'v2' if not re.match(r'^v\d+$', segment): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Resource segment '{segment}' should use kebab-case", path=f"/paths/{path}", suggestion=f"Use kebab-case for '{segment}' (e.g., 'user-profiles', 'order-items')" )) has_issues = True # Check for verb usage in URLs (anti-pattern) common_verbs = {'get', 'post', 'put', 'delete', 'create', 'update', 'remove', 'add'} if segment.lower() in common_verbs: self.report.add_issue(LintIssue( severity='warning', category='rest_conventions', message=f"Avoid verbs in URLs: '{segment}' in {path}", path=f"/paths/{path}", suggestion="Use HTTP methods instead of verbs in URLs. Use nouns for resources." )) has_issues = True # Check path depth (avoid over-nesting) if len(segments) > 6: self.report.add_issue(LintIssue( severity='warning', category='url_structure', message=f"Path has excessive nesting ({len(segments)} levels): {path}", path=f"/paths/{path}", suggestion="Consider flattening the resource hierarchy or using query parameters" )) has_issues = True # Check for consistent versioning if any('v' + str(i) in segments for i in range(1, 10)): version_segments = [seg for seg in segments if re.match(r'^v\d+$', seg)] if len(version_segments) > 1: self.report.add_issue(LintIssue( severity='error', category='versioning', message=f"Multiple version segments in path: {path}", path=f"/paths/{path}", suggestion="Use only one version segment per path" )) has_issues = True return has_issues def _validate_operation(self, path: str, method: str, operation: Dict[str, Any]) -> bool: """Validate individual operation (HTTP method + path combination).""" has_issues = False operation_path = f"/paths/{path}/{method.lower()}" # Check for required operation fields if 'responses' not in operation: self.report.add_issue(LintIssue( severity='error', category='structure', message=f"Missing responses section for {method} {path}", path=f"{operation_path}/responses", suggestion="Define expected responses for this operation" )) has_issues = True # Check for operation documentation if 'summary' not in operation: self.report.add_issue(LintIssue( severity='warning', category='documentation', message=f"Missing summary for {method} {path}", path=f"{operation_path}/summary", suggestion="Add a brief summary describing what this operation does" )) has_issues = True if 'description' not in operation: self.report.add_issue(LintIssue( severity='info', category='documentation', message=f"Missing description for {method} {path}", path=f"{operation_path}/description", suggestion="Add a detailed description for better API documentation" )) has_issues = True # Validate HTTP method usage patterns method_issues = self._validate_http_method_usage(path, method, operation) if method_issues: has_issues = True # Validate responses if 'responses' in operation: response_issues = self._validate_responses(path, method, operation['responses']) if response_issues: has_issues = True # Validate parameters if 'parameters' in operation: param_issues = self._validate_parameters(path, method, operation['parameters']) if param_issues: has_issues = True # Validate request body if 'requestBody' in operation: body_issues = self._validate_request_body(path, method, operation['requestBody']) if body_issues: has_issues = True return has_issues def _validate_http_method_usage(self, path: str, method: str, operation: Dict[str, Any]) -> bool: """Validate proper HTTP method usage patterns.""" has_issues = False # GET requests should not have request body if method == 'GET' and 'requestBody' in operation: self.report.add_issue(LintIssue( severity='error', category='rest_conventions', message=f"GET request should not have request body: {method} {path}", path=f"/paths/{path}/{method.lower()}/requestBody", suggestion="Remove requestBody from GET request or use POST if body is needed" )) has_issues = True # DELETE requests typically should not have request body if method == 'DELETE' and 'requestBody' in operation: self.report.add_issue(LintIssue( severity='warning', category='rest_conventions', message=f"DELETE request typically should not have request body: {method} {path}", path=f"/paths/{path}/{method.lower()}/requestBody", suggestion="Consider using query parameters or path parameters instead" )) has_issues = True # POST/PUT/PATCH should typically have request body (except for actions) if method in ['POST', 'PUT', 'PATCH'] and 'requestBody' not in operation: # Check if this is an action endpoint if not any(action in path.lower() for action in ['activate', 'deactivate', 'reset', 'confirm']): self.report.add_issue(LintIssue( severity='info', category='rest_conventions', message=f"{method} request typically should have request body: {method} {path}", path=f"/paths/{path}/{method.lower()}", suggestion=f"Consider adding requestBody for {method} operation or use GET if no data is being sent" )) has_issues = True return has_issues def _validate_responses(self, path: str, method: str, responses: Dict[str, Any]) -> bool: """Validate response definitions.""" has_issues = False # Check for success response success_codes = {'200', '201', '202', '204'} has_success = any(code in responses for code in success_codes) if not has_success: self.report.add_issue(LintIssue( severity='error', category='responses', message=f"Missing success response for {method} {path}", path=f"/paths/{path}/{method.lower()}/responses", suggestion="Define at least one success response (200, 201, 202, or 204)" )) has_issues = True # Check for error responses has_error_responses = any(code.startswith('4') or code.startswith('5') for code in responses.keys()) if not has_error_responses: self.report.add_issue(LintIssue( severity='warning', category='responses', message=f"Missing error responses for {method} {path}", path=f"/paths/{path}/{method.lower()}/responses", suggestion="Define common error responses (400, 404, 500, etc.)" )) has_issues = True # Validate individual response codes for status_code, response in responses.items(): if status_code == 'default': continue try: code_int = int(status_code) except ValueError: self.report.add_issue(LintIssue( severity='error', category='responses', message=f"Invalid status code '{status_code}' for {method} {path}", path=f"/paths/{path}/{method.lower()}/responses/{status_code}", suggestion="Use valid HTTP status codes (e.g., 200, 404, 500)" )) has_issues = True continue # Check if status code is appropriate for the method expected_codes = self.standard_status_codes.get(method, set()) common_codes = {400, 401, 403, 404, 429, 500} # Always acceptable if expected_codes and code_int not in expected_codes and code_int not in common_codes: self.report.add_issue(LintIssue( severity='info', category='responses', message=f"Uncommon status code {status_code} for {method} {path}", path=f"/paths/{path}/{method.lower()}/responses/{status_code}", suggestion=f"Consider using standard codes for {method}: {sorted(expected_codes)}" )) has_issues = True return has_issues def _validate_parameters(self, path: str, method: str, parameters: List[Dict[str, Any]]) -> bool: """Validate parameter definitions.""" has_issues = False for i, param in enumerate(parameters): param_path = f"/paths/{path}/{method.lower()}/parameters[{i}]" # Check required fields if 'name' not in param: self.report.add_issue(LintIssue( severity='error', category='parameters', message=f"Parameter missing name field in {method} {path}", path=f"{param_path}/name", suggestion="Add a name field to the parameter" )) has_issues = True continue if 'in' not in param: self.report.add_issue(LintIssue( severity='error', category='parameters', message=f"Parameter '{param['name']}' missing 'in' field in {method} {path}", path=f"{param_path}/in", suggestion="Specify parameter location (query, path, header, cookie)" )) has_issues = True # Validate parameter naming param_name = param['name'] param_location = param.get('in', '') if param_location == 'query': # Query parameters should use camelCase or kebab-case if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Query parameter '{param_name}' should use camelCase or kebab-case in {method} {path}", path=f"{param_path}/name", suggestion="Use camelCase (e.g., 'pageSize') or kebab-case (e.g., 'page-size')" )) has_issues = True elif param_location == 'path': # Path parameters should use camelCase or kebab-case if not self.camel_case_pattern.match(param_name) and not self.kebab_case_pattern.match(param_name): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Path parameter '{param_name}' should use camelCase or kebab-case in {method} {path}", path=f"{param_path}/name", suggestion="Use camelCase (e.g., 'userId') or kebab-case (e.g., 'user-id')" )) has_issues = True # Path parameters must be required if not param.get('required', False): self.report.add_issue(LintIssue( severity='error', category='parameters', message=f"Path parameter '{param_name}' must be required in {method} {path}", path=f"{param_path}/required", suggestion="Set required: true for path parameters" )) has_issues = True return has_issues def _validate_request_body(self, path: str, method: str, request_body: Dict[str, Any]) -> bool: """Validate request body definition.""" has_issues = False if 'content' not in request_body: self.report.add_issue(LintIssue( severity='error', category='request_body', message=f"Request body missing content for {method} {path}", path=f"/paths/{path}/{method.lower()}/requestBody/content", suggestion="Define content types for the request body" )) has_issues = True return has_issues def _validate_components_section(self) -> None: """Validate the components section.""" if 'components' not in self.openapi_spec: self.report.add_issue(LintIssue( severity='info', category='structure', message="Missing components section", path="/components", suggestion="Consider defining reusable components (schemas, responses, parameters)" )) return components = self.openapi_spec['components'] # Validate schemas if 'schemas' in components: self._validate_schemas(components['schemas']) def _validate_schemas(self, schemas: Dict[str, Any]) -> None: """Validate schema definitions.""" for schema_name, schema in schemas.items(): # Check schema naming (should be PascalCase) if not self.pascal_case_pattern.match(schema_name): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Schema name '{schema_name}' should use PascalCase", path=f"/components/schemas/{schema_name}", suggestion=f"Use PascalCase for schema names (e.g., 'UserProfile', 'OrderItem')" )) # Validate schema properties if isinstance(schema, dict) and 'properties' in schema: self._validate_schema_properties(schema_name, schema['properties']) def _validate_schema_properties(self, schema_name: str, properties: Dict[str, Any]) -> None: """Validate schema property naming.""" for prop_name, prop_def in properties.items(): # Properties should use camelCase if not self.camel_case_pattern.match(prop_name): self.report.add_issue(LintIssue( severity='warning', category='naming', message=f"Property '{prop_name}' in schema '{schema_name}' should use camelCase", path=f"/components/schemas/{schema_name}/properties/{prop_name}", suggestion="Use camelCase for property names (e.g., 'firstName', 'createdAt')" )) def _validate_security_section(self) -> None: """Validate security definitions.""" if 'security' not in self.openapi_spec and 'components' not in self.openapi_spec: self.report.add_issue(LintIssue( severity='warning', category='security', message="No security configuration found", path="/security", suggestion="Define security schemes and apply them to operations" )) def _validate_raw_endpoint_structure(self) -> None: """Validate structure of raw endpoint definitions.""" if 'endpoints' not in self.raw_endpoints: self.report.add_issue(LintIssue( severity='error', category='structure', message="Missing 'endpoints' field in raw endpoint definition", path="/endpoints", suggestion="Provide an 'endpoints' object containing endpoint definitions" )) return endpoints = self.raw_endpoints['endpoints'] self.report.total_endpoints = len(endpoints) def _lint_raw_endpoint(self, path: str, endpoint_data: Dict[str, Any]) -> None: """Lint individual raw endpoint definition.""" # Validate path structure self._validate_path_structure(path) # Check for required fields if 'method' not in endpoint_data: self.report.add_issue(LintIssue( severity='error', category='structure', message=f"Missing method field for endpoint {path}", path=f"/endpoints/{path}/method", suggestion="Specify HTTP method (GET, POST, PUT, PATCH, DELETE)" )) return method = endpoint_data['method'].upper() if method not in self.http_methods: self.report.add_issue(LintIssue( severity='error', category='structure', message=f"Invalid HTTP method '{method}' for endpoint {path}", path=f"/endpoints/{path}/method", suggestion=f"Use valid HTTP methods: {', '.join(sorted(self.http_methods))}" )) def generate_json_report(self) -> str: """Generate JSON format report.""" issues_by_severity = self.report.get_issues_by_severity() report_data = { "summary": { "total_endpoints": self.report.total_endpoints, "endpoints_with_issues": self.report.endpoints_with_issues, "total_issues": len(self.report.issues), "errors": len(issues_by_severity['error']), "warnings": len(issues_by_severity['warning']), "info": len(issues_by_severity['info']), "score": round(self.report.score, 2) }, "issues": [] } for issue in self.report.issues: report_data["issues"].append({ "severity": issue.severity, "category": issue.category, "message": issue.message, "path": issue.path, "suggestion": issue.suggestion }) return json.dumps(report_data, indent=2) def generate_text_report(self) -> str: """Generate human-readable text report.""" issues_by_severity = self.report.get_issues_by_severity() report_lines = [ "═══════════════════════════════════════════════════════════════", " API LINTING REPORT", "═══════════════════════════════════════════════════════════════", "", "SUMMARY:", f" Total Endpoints: {self.report.total_endpoints}", f" Endpoints with Issues: {self.report.endpoints_with_issues}", f" Overall Score: {self.report.score:.1f}/100.0", "", "ISSUE BREAKDOWN:", f" 🔴 Errors: {len(issues_by_severity['error'])}", f" 🟡 Warnings: {len(issues_by_severity['warning'])}", f" ℹ️ Info: {len(issues_by_severity['info'])}", "", ] if not self.report.issues: report_lines.extend([ "🎉 Congratulations! No issues found in your API specification.", "" ]) else: # Group issues by category issues_by_category = {} for issue in self.report.issues: if issue.category not in issues_by_category: issues_by_category[issue.category] = [] issues_by_category[issue.category].append(issue) for category, issues in issues_by_category.items(): report_lines.append(f"{'═' * 60}") report_lines.append(f"CATEGORY: {category.upper().replace('_', ' ')}") report_lines.append(f"{'═' * 60}") for issue in issues: severity_icon = {"error": "🔴", "warning": "🟡", "info": "ℹ️"}[issue.severity] report_lines.extend([ f"{severity_icon} {issue.severity.upper()}: {issue.message}", f" Path: {issue.path}", ]) if issue.suggestion: report_lines.append(f" 💡 Suggestion: {issue.suggestion}") report_lines.append("") # Add scoring breakdown report_lines.extend([ "═══════════════════════════════════════════════════════════════", "SCORING DETAILS:", "═══════════════════════════════════════════════════════════════", f"Base Score: 100.0", f"Errors Penalty: -{len(issues_by_severity['error']) * 10} (10 points per error)", f"Warnings Penalty: -{len(issues_by_severity['warning']) * 3} (3 points per warning)", f"Info Penalty: -{len(issues_by_severity['info']) * 1} (1 point per info)", f"Final Score: {self.report.score:.1f}/100.0", "" ]) # Add recommendations based on score if self.report.score >= 90: report_lines.append("🏆 Excellent! Your API design follows best practices.") elif self.report.score >= 80: report_lines.append("✅ Good API design with minor areas for improvement.") elif self.report.score >= 70: report_lines.append("⚠️ Fair API design. Consider addressing warnings and errors.") elif self.report.score >= 50: report_lines.append("❌ Poor API design. Multiple issues need attention.") else: report_lines.append("🚨 Critical API design issues. Immediate attention required.") return "\n".join(report_lines) def main(): """Main CLI entry point.""" parser = argparse.ArgumentParser( description="Analyze OpenAPI/Swagger specifications for REST conventions and best practices", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: python api_linter.py openapi.json python api_linter.py --format json openapi.json > report.json python api_linter.py --raw-endpoints endpoints.json """ ) parser.add_argument( 'input_file', help='Input file: OpenAPI/Swagger JSON file or raw endpoints JSON' ) parser.add_argument( '--format', choices=['text', 'json'], default='text', help='Output format (default: text)' ) parser.add_argument( '--raw-endpoints', action='store_true', help='Treat input as raw endpoint definitions instead of OpenAPI spec' ) parser.add_argument( '--output', help='Output file (default: stdout)' ) args = parser.parse_args() # Load input file try: with open(args.input_file, 'r') as f: input_data = json.load(f) except FileNotFoundError: print(f"Error: Input file '{args.input_file}' not found.", file=sys.stderr) return 1 except json.JSONDecodeError as e: print(f"Error: Invalid JSON in '{args.input_file}': {e}", file=sys.stderr) return 1 # Initialize linter and run analysis linter = APILinter() try: if args.raw_endpoints: report = linter.lint_raw_endpoints(input_data) else: report = linter.lint_openapi_spec(input_data) except Exception as e: print(f"Error during linting: {e}", file=sys.stderr) return 1 # Generate report if args.format == 'json': output = linter.generate_json_report() else: output = linter.generate_text_report() # Write output if args.output: try: with open(args.output, 'w') as f: f.write(output) print(f"Report written to {args.output}") except IOError as e: print(f"Error writing to '{args.output}': {e}", file=sys.stderr) return 1 else: print(output) # Return appropriate exit code error_count = len([i for i in report.issues if i.severity == 'error']) return 1 if error_count > 0 else 0 if __name__ == '__main__': sys.exit(main())