#!/usr/bin/env nu # Detect Breaking Changes Script # Detects API changes and breaking changes between Nushell versions # # Usage: # detect_breaking_changes.nu 0.107.1 0.108.0 # Compare two versions # detect_breaking_changes.nu --scan-plugins # Scan plugins for usage of breaking APIs # detect_breaking_changes.nu --export # Export breaking changes report use lib/common_lib.nu * # Known breaking changes database const BREAKING_CHANGES = { "0.108.0": [ { type: "command_rename" old_name: "into value" new_name: "detect type" description: "Command renamed and behavior changed - doesn't operate on cells anymore" impact: "high" migration: "Replace 'into value' with 'detect type' and review usage for cell operations" }, { type: "behavior_change" component: "stream_error_handling" description: "Collecting a stream that contains errors now raises an error itself" impact: "medium" migration: "Add explicit error handling when collecting streams that might contain errors" }, { type: "feature_addition" feature: "mcp" description: "MCP (Model Context Protocol) feature added as optional feature" impact: "none" migration: "Enable with: cargo build --features mcp" } ] } # Main entry point def main [ from_version?: string # Source version (e.g., "0.107.1") to_version?: string # Target version (e.g., "0.108.0") --scan-plugins # Scan plugins for breaking API usage --export # Export breaking changes report ] { log_info "Nushell Breaking Changes Detector" # If versions provided, show breaking changes between them if ($from_version | is-not-empty) and ($to_version | is-not-empty) { show_breaking_changes $from_version $to_version } else if $scan_plugins { scan_plugins_for_breaking_changes } else { # Show all known breaking changes show_all_breaking_changes } if $export { export_breaking_changes_report } } # Show breaking changes between two versions def show_breaking_changes [ from_version: string to_version: string ] { log_info $"Analyzing breaking changes: [$from_version] → [$to_version]" # Get breaking changes for target version let changes = get_breaking_changes_for_version $to_version if ($changes | is-empty) { log_success $"No known breaking changes between [$from_version] and [$to_version]" return } log_warn $"Found ($changes | length) breaking changes in version [$to_version]" # Display by impact level display_breaking_changes_by_impact $changes } # Get breaking changes for a specific version def get_breaking_changes_for_version [ version: string ]: nothing -> list { $BREAKING_CHANGES | get -i $version | default [] } # Display breaking changes organized by impact def display_breaking_changes_by_impact [ changes: list ] { # Group by impact let high_impact = $changes | where impact == "high" let medium_impact = $changes | where impact == "medium" let low_impact = $changes | where impact == "low" or impact == "none" if ($high_impact | length) > 0 { log_error "\n=== HIGH IMPACT CHANGES ===" display_change_list $high_impact } if ($medium_impact | length) > 0 { log_warn "\n=== MEDIUM IMPACT CHANGES ===" display_change_list $medium_impact } if ($low_impact | length) > 0 { log_info "\n=== LOW/NO IMPACT CHANGES ===" display_change_list $low_impact } } # Display list of changes def display_change_list [ changes: list ] { $changes | each {|change| let type_badge = match $change.type { "command_rename" => "🔄 RENAME", "behavior_change" => "⚠️ BEHAVIOR", "api_removal" => "❌ REMOVAL", "feature_addition" => "✨ FEATURE", _ => "📝 CHANGE" } print $"\n ($type_badge)" if ($change | get -i old_name | is-not-empty) { print $" Old: ($change.old_name) → New: ($change.new_name)" } if ($change | get -i component | is-not-empty) { print $" Component: ($change.component)" } if ($change | get -i feature | is-not-empty) { print $" Feature: ($change.feature)" } print $" Description: ($change.description)" print $" Migration: ($change.migration)" } } # Show all known breaking changes def show_all_breaking_changes [] { log_info "All Known Breaking Changes" let all_versions = $BREAKING_CHANGES | columns | sort $all_versions | each {|version| let changes = $BREAKING_CHANGES | get $version log_info $"\n=== Version ($version) - ($changes | length) changes ===" display_breaking_changes_by_impact $changes } } # Scan plugins for usage of breaking APIs def scan_plugins_for_breaking_changes [] { log_info "Scanning plugins for breaking API usage..." let plugin_dirs = get_plugin_directories log_info $"Scanning ($plugin_dirs | length) custom plugins" mut findings = [] for plugin_dir in $plugin_dirs { let plugin_name = get_plugin_name $plugin_dir log_info $"Scanning: [$plugin_name]..." let plugin_findings = scan_plugin_directory $plugin_dir if ($plugin_findings | length) > 0 { $findings = ($findings | append { plugin: $plugin_name findings: $plugin_findings }) } } # Display results if ($findings | length) > 0 { log_warn $"\n=== Breaking API Usage Found ===" $findings | each {|result| log_warn $"\n[$result.plugin]" $result.findings | each {|finding| print $" • ($finding.pattern) in ($finding.file):($finding.line)" print $" Context: ($finding.context)" } } log_info "\nReview these findings and update code accordingly" } else { log_success "No breaking API usage detected!" } } # Scan a plugin directory for breaking API usage def scan_plugin_directory [ plugin_dir: string ]: nothing -> list { mut findings = [] # Get all Rust source files let rust_files = glob $"($plugin_dir)/src/**/*.rs" for file in $rust_files { # Check for "into value" usage (breaking in 0.108.0) let into_value_matches = try { open $file | lines | enumerate | where {|row| $row.item =~ "into value"} } catch { [] } if ($into_value_matches | length) > 0 { for match in $into_value_matches { $findings = ($findings | append { pattern: "into value (renamed to 'detect type')" file: $file line: ($match.index + 1) context: ($match.item | str trim) }) } } # Check for stream collection patterns (behavior changed in 0.108.0) let collect_matches = try { open $file | lines | enumerate | where {|row| $row.item =~ "collect\(\)" and $row.item =~ "stream"} } catch { [] } if ($collect_matches | length) > 0 { for match in $collect_matches { $findings = ($findings | append { pattern: "stream.collect() (error behavior changed)" file: $file line: ($match.index + 1) context: ($match.item | str trim) }) } } } $findings } # Export breaking changes report def export_breaking_changes_report [] { let output_file = "./tmp/breaking_changes_report.json" ensure_dir "./tmp" let report = { generated_at: (date now | format date "%Y-%m-%d %H:%M:%S") breaking_changes_database: $BREAKING_CHANGES versions: ($BREAKING_CHANGES | columns) } $report | to json | save -f $output_file log_success $"Breaking changes report exported to: ($output_file)" } # Check if code is affected by breaking changes def "main check-code" [ code_snippet: string # Code to check ] { log_info "Checking code for breaking API usage..." mut issues = [] # Check for "into value" if ($code_snippet =~ "into value") { $issues = ($issues | append { pattern: "into value" severity: "high" suggestion: "Replace with 'detect type' (note: behavior changed)" }) } # Check for direct stream collection if ($code_snippet =~ "\.collect\(\)") { $issues = ($issues | append { pattern: "stream.collect()" severity: "medium" suggestion: "Add explicit error handling for stream errors" }) } # Display results if ($issues | length) > 0 { log_warn "Potential breaking API usage detected:" $issues | each {|issue| let severity_color = if $issue.severity == "high" { "red" } else { "yellow" } print $" (ansi $severity_color)• ($issue.pattern)(ansi reset)" print $" → ($issue.suggestion)" } } else { log_success "No breaking API usage detected in code snippet" } } # Generate migration guide for a specific version def "main migration-guide" [ version: string # Version to generate guide for ] { log_info $"Generating migration guide for version [$version]" let changes = get_breaking_changes_for_version $version if ($changes | is-empty) { log_info $"No breaking changes documented for version [$version]" return } # Generate markdown migration guide let guide = generate_migration_markdown $version $changes let output_file = $"./tmp/MIGRATION_($version).md" ensure_dir "./tmp" $guide | save -f $output_file log_success $"Migration guide generated: ($output_file)" } # Generate migration guide in markdown format def generate_migration_markdown [ version: string changes: list ]: nothing -> string { mut content = $"# Migration Guide to Nushell ($version)\n\n" $content = $"($content)Generated: (date now | format date '%Y-%m-%d %H:%M:%S')\n\n" $content = $"($content)## Breaking Changes\n\n" $content = $"($content)This guide covers ($changes | length) breaking changes in Nushell ($version).\n\n" # Group by impact let high_impact = $changes | where impact == "high" let medium_impact = $changes | where impact == "medium" let low_impact = $changes | where impact == "low" or impact == "none" if ($high_impact | length) > 0 { $content = $"($content)### ⚠️ High Impact Changes\n\n" for change in $high_impact { $content = ($content + (format_change_markdown $change) + "\n") } } if ($medium_impact | length) > 0 { $content = $"($content)### 📝 Medium Impact Changes\n\n" for change in $medium_impact { $content = ($content + (format_change_markdown $change) + "\n") } } if ($low_impact | length) > 0 { $content = $"($content)### ✨ Low/No Impact Changes\n\n" for change in $low_impact { $content = ($content + (format_change_markdown $change) + "\n") } } $content } # Format single change as markdown def format_change_markdown [ change: record ]: nothing -> string { mut content = $"#### ($change.type | str title-case)\n\n" if ($change | get -i old_name | is-not-empty) { $content = $"($content)**Old**: `($change.old_name)` → **New**: `($change.new_name)`\n\n" } $content = $"($content)**Description**: ($change.description)\n\n" $content = $"($content)**Migration**: ($change.migration)\n\n" $content } # Add a new breaking change to the database def "main add" [ version: string # Version this applies to type: string # Type of change description: string # Description impact: string # Impact level (high/medium/low/none) migration: string # Migration instructions --old-name: string # Old name (for renames) --new-name: string # New name (for renames) ] { log_info "This command would add a new breaking change to the database" log_warn "Note: The database is currently hardcoded in the script" log_info "For production use, consider storing in external TOML/JSON file" let new_change = { type: $type description: $description impact: $impact migration: $migration } # Add optional fields let new_change = if ($old_name | is-not-empty) { $new_change | merge {old_name: $old_name} } else { $new_change } let new_change = if ($new_name | is-not-empty) { $new_change | merge {new_name: $new_name} } else { $new_change } log_info "New change to add:" print ($new_change | to json) }