functions/helpers/Convert-LogFunctions.ps1

function Convert-LogFunctions {
    <#
    .SYNOPSIS
        Conservatively converts logging command calls in a script for manual review.
    .DESCRIPTION
        Converts simple Write-Verbose, Write-Debug, Write-Information, Write-Warning and
        Write-Host calls using the PowerShell AST. Strings, comments and Write-Error are
        preserved. Unsupported calls are reported as Skipped. Review the diff before use.
        Creates a .bak backup and preserves Unicode encoding, BOM and line endings.
        BOM-less input must be valid UTF-8; ambiguous legacy encodings are refused.
    .PARAMETER FilePath
        Literal path to a script. Path and FullName are aliases; accepts pipeline input.
    .PARAMETER Force
        Allows replacing an existing .bak backup.
    .OUTPUTS
        Conversion findings with File, Line, Command, Action, Before, After and Reason.
    .EXAMPLE
        Convert-LogFunctions -FilePath C:\myscript.ps1 -WhatIf
    #>

    [CmdletBinding(SupportsShouldProcess = $true)]
    param(
        [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)]
        [Alias('Path', 'FullName')]
        [string]$FilePath,
        [switch]$Force
    )

    begin {
        function Apply-ConversionEdits($Text, $Edits, [int]$Origin = 0) {
            $builder = [Text.StringBuilder]::new($Text)
            foreach ($edit in ($Edits | Sort-Object Offset -Descending)) {
                [void]$builder.Remove($edit.Offset - $Origin, $edit.Length)
                [void]$builder.Insert($edit.Offset - $Origin, $edit.Text)
            }
            $builder.ToString()
        }

        function Get-ConversionSkipReason($Command, $Name, $Target, $Edits) {
            for ($parent = $Command.Parent; $null -ne $parent; $parent = $parent.Parent) {
                if ($parent -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) {
                    return 'Commands inside strings require manual review.'
                }
            }
            if ($Command.InvocationOperator -ne [System.Management.Automation.Language.TokenKind]::Unknown) {
                return 'Invocation operators require manual review.'
            }
            if ($Command.Parent -is [System.Management.Automation.Language.PipelineAst] -and
                $Command.Parent.PipelineElements[0] -ne $Command) { return 'Pipeline input requires manual review.' }
            if ($Command.Redirections.Count -ne 0) { return 'Stream redirection requires manual review.' }
            if ($Command.FindAll({ param($node) $node -is [System.Management.Automation.Language.VariableExpressionAst] -and $node.Splatted }, $true).Count) {
                return 'Splatting cannot be checked statically.'
            }
            $source = Get-Command "Microsoft.PowerShell.Utility\$Name" -CommandType Cmdlet -ErrorAction Stop
            $targetParameters = (Get-Command $Target -CommandType Function -ErrorAction Stop).Parameters
            $message = switch ($Name) { 'Write-Information' { 'MessageData' }; 'Write-Host' { 'Object' }; default { 'Message' } }
            $positional = 0
            $hasMessage = $false
            for ($i = 1; $i -lt $Command.CommandElements.Count; $i++) {
                $element = $Command.CommandElements[$i]
                if ($element -is [System.Management.Automation.Language.CommandParameterAst]) {
                    $candidates = @($source.Parameters.Values | Where-Object {
                        $_.Name -ieq $element.ParameterName -or $_.Aliases -icontains $element.ParameterName
                    })
                    if ($candidates.Count -eq 0) {
                        $candidates = @($source.Parameters.Values | Where-Object {
                            $matchesPrefix = $_.Name.StartsWith($element.ParameterName, [StringComparison]::OrdinalIgnoreCase)
                            foreach ($alias in $_.Aliases) { $matchesPrefix = $matchesPrefix -or $alias.StartsWith($element.ParameterName, [StringComparison]::OrdinalIgnoreCase) }
                            $matchesPrefix
                        })
                    }
                    if ($candidates.Count -ne 1) { return "Unknown or ambiguous parameter: -$($element.ParameterName)" }
                    $resolved = $candidates[0]
                    $isMessage = $resolved.Name -ieq $message
                    if (-not $isMessage -and -not $targetParameters.ContainsKey($resolved.Name)) {
                        return "Target function does not support parameter -$($resolved.Name)."
                    }
                    if ($isMessage) { $hasMessage = $true }
                    $replacement = if ($isMessage) { 'MessageTemplate' } else { $resolved.Name }
                    $Edits.Add([pscustomobject]@{ Offset = $element.Extent.StartOffset; Length = 1 + $element.ParameterName.Length; Text = "-$replacement" })
                    if ($resolved.ParameterType -ne [System.Management.Automation.SwitchParameter] -and $null -eq $element.Argument) {
                        $i++
                        if ($i -ge $Command.CommandElements.Count -or $Command.CommandElements[$i] -is [System.Management.Automation.Language.CommandParameterAst]) {
                            return 'Parameter value cannot be determined safely.'
                        }
                    }
                } else {
                    if ($element.Extent.Text -in @('--%', '--')) { return 'Argument parsing markers require manual review.' }
                    $positional++
                }
            }
            $maximum = if ($hasMessage) { 0 } else { 1 }
            if ($positional -gt $maximum) { return 'Multiple message arguments require manual review.' }
            if (-not $hasMessage -and $positional -eq 0) { return 'No explicit message was supplied.' }
            $extent = $Command.CommandElements[0].Extent
            $Edits.Add([pscustomobject]@{ Offset = $extent.StartOffset; Length = $extent.EndOffset - $extent.StartOffset; Text = $Target })
        }
    }

    process {
        try {
            $path = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($FilePath)
            $original = [IO.File]::ReadAllBytes($path)
            $reader = [IO.StreamReader]::new([IO.MemoryStream]::new($original), [Text.UTF8Encoding]::new($false, $true), $true)
            try {
                [void]$reader.Peek()
                $encoding = $reader.CurrentEncoding.Clone()
            } finally { $reader.Dispose() }
            $encoding.DecoderFallback = [Text.DecoderFallback]::ExceptionFallback
            $encoding.EncoderFallback = [Text.EncoderFallback]::ExceptionFallback
            $preamble = $encoding.GetPreamble()
            $bom = 0
            if ($preamble.Length -gt 0 -and $original.Length -ge $preamble.Length -and
                [Convert]::ToBase64String($original, 0, $preamble.Length) -eq [Convert]::ToBase64String($preamble)) { $bom = $preamble.Length }
            $text = $encoding.GetString($original, $bom, $original.Length - $bom)
            $tokens = $null
            $parseErrors = $null
            # Parse decoded text so BOM-less UTF-8 has identical offsets on 5.1 and 7.
            $ast = [System.Management.Automation.Language.Parser]::ParseInput($text, $path, [ref]$tokens, [ref]$parseErrors)
            if ($parseErrors.Count -gt 0) { throw [System.Management.Automation.ParseException]::new($parseErrors) }
            $edits = [Collections.Generic.List[object]]::new()
            $findings = [Collections.Generic.List[object]]::new()
            $targets = @{ 'Write-Verbose' = 'Write-VerboseLog'; 'Write-Debug' = 'Write-DebugLog'; 'Write-Information' = 'Write-InfoLog'; 'Write-Host' = 'Write-InfoLog'; 'Write-Warning' = 'Write-WarningLog'; 'Write-Error' = '' }
            foreach ($command in $ast.FindAll({ param($node) $node -is [System.Management.Automation.Language.CommandAst] }, $true)) {
                $name = $command.GetCommandName()
                if ($null -eq $name -or -not $targets.ContainsKey($name)) { continue }
                $local = [Collections.Generic.List[object]]::new()
                $reason = if ($name -ieq 'Write-Error') { 'Write-Error must retain PowerShell error semantics.' } else { Get-ConversionSkipReason $command $name $targets[$name] $local }
                $before = $command.Extent.Text
                $after = $before
                $action = 'Skipped'
                if ($null -eq $reason) {
                    $after = Apply-ConversionEdits $before $local $command.Extent.StartOffset
                    $edits.AddRange($local)
                    $action = 'Converted'
                    $reason = 'Command and parameter names converted; review before use.'
                }
                $findings.Add([pscustomobject]@{ File = $path; Line = $command.Extent.StartLineNumber; Command = $name; Action = $action; Before = $before; After = $after; Reason = $reason })
            }
            if ($edits.Count -gt 0) {
                if ($PSCmdlet.ShouldProcess($path, "Back up original to $path.bak and convert logging calls")) {
                    if ([Convert]::ToBase64String($original) -cne [Convert]::ToBase64String([IO.File]::ReadAllBytes($path))) {
                        throw [IO.IOException]::new('The file changed during conversion; no changes were written.')
                    }
                    $body = $encoding.GetBytes((Apply-ConversionEdits $text $edits))
                    $output = [byte[]]::new($bom + $body.Length)
                    [Array]::Copy($original, 0, $output, 0, $bom)
                    [Array]::Copy($body, 0, $output, $bom, $body.Length)
                    [IO.File]::Copy($path, "$path.bak", $Force.IsPresent)
                    $temporary = $path + '.' + [guid]::NewGuid().ToString('N') + '.tmp'
                    try {
                        [IO.File]::WriteAllBytes($temporary, $output)
                        [IO.File]::Replace($temporary, $path, [System.Management.Automation.Language.NullString]::Value)
                    } finally {
                        if ([IO.File]::Exists($temporary)) { [IO.File]::Delete($temporary) }
                    }
                } else {
                    foreach ($finding in $findings) {
                        if ($finding.Action -eq 'Converted') {
                            $finding.Action = 'Skipped'
                            $finding.Reason = 'ShouldProcess declined (WhatIf or confirmation); After shows the proposed change.'
                        }
                    }
                }
            }
            $findings
        } catch { $PSCmdlet.WriteError($_) }
    }
}