Public/Update-DhScript.ps1

function Update-DhScript {
    <#
    .SYNOPSIS
        Audit - and optionally rewrite - a DashHtml script for the 3.0.0 upgrade.

    .DESCRIPTION
        Parses each .ps1 with the PowerShell AST (it never executes them) and
        reports every call affected by a breaking change. With -Apply it rewrites
        the file, writing a timestamped backup first. It is idempotent: a
        rewritten script reports nothing.

        What it looks for:

          ACTION -NavGroup / -NavSubGroup (removed in 3.0.0) on any nav-aware
                   cmdlet. Rewritten to -NavPath with the mapping the 2.x compat
                   shim applied, so the menu is unchanged:
                     table -NavGroup 'G' [-NavSubGroup 'S'] -> -NavPath 'G[/S]/<TableId>'
                     block -NavGroup 'G' [-NavSubGroup 'S'] -> -NavPath 'G[/S]' -NavScope Subtree
                   (an explicit -NavScope is kept). A value that is not a literal
                   string (a variable or expression) is reported for a human.
          ACTION A 'Bands' key in a script that calls Add-DhBullet (removed in
                   3.0.0): renamed to 'Thresholds', which has the same shape.
          ACTION -AllowHtml (2.0.0 default flip $true -> $false) on
                   Add-DhHtmlBlock, Add-DhCollapsible, Add-DhAlertBanner and
                   Add-DhTabs: a call passing literal markup WITHOUT an explicit
                   -AllowHtml renders it as text. Rewritten by adding
                   -AllowHtml:$true; non-literal content is reported for a human.
                   Only scripts written for 1.x hit this.

        AST parsing rather than text matching is not a stylistic choice: these
        calls routinely span several lines with backtick continuation, so a
        line-oriented pattern silently under-reports.

    .PARAMETER Path
        Files or directories to scan. Directories recurse. Default: current dir.

    .PARAMETER Apply
        Rewrite the files. Without it, nothing is modified. A rewritten file is
        parsed again; if it no longer parses, the backup is restored.

    .PARAMETER BackupPath
        Directory for backups when -Apply is used. Default: '<repo>\Backups'.

    .PARAMETER PassThru
        Emit the finding objects instead of a formatted report.

    .EXAMPLE
        Update-DhScript -Path .\Examples

    .EXAMPLE
        Update-DhScript -Path .\Examples -Apply

    .EXAMPLE
        Update-DhScript -Path C:\Scripts -PassThru | Where-Object Severity -eq 'ACTION'
    #>

    # Write-Host is deliberate here: this cmdlet's product IS a console report
    # for a human running an upgrade. Its machine-readable output goes through
    # -PassThru, which emits objects to the pipeline as normal.
    [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '',
        Justification = 'Interactive upgrade report; structured output is available via -PassThru.')]
    [CmdletBinding(SupportsShouldProcess)]
    param(
        [string[]] $Path = @('.'),
        [switch]   $Apply,
        [string]   $BackupPath = '',
        [switch]   $PassThru
    )

    $htmlCmds = @('Add-DhHtmlBlock','Add-DhCollapsible','Add-DhAlertBanner','Add-DhTabs')
    $blockCmds = @(
        'Add-DhBarChart','Add-DhPieChart','Add-DhLineChart','Add-DhBullet','Add-DhSummary',
        'Add-DhHtmlBlock','Add-DhCollapsible','Add-DhStatusGrid','Add-DhHeatmap',
        'Add-DhTopologyMap','Add-DhEventFeed','Add-DhTabs','Add-DhFilterCard','Add-DhAlertBanner',
        'Add-DhRangeBar',  # v2.1.4 - missed when Add-DhRangeBar shipped in 2.1.0
        'Add-DhFindings',  # DashHtml 2.2.0
        'Add-DhKeyValue','Add-DhRecordList',   # DashHtml 2.3.0
        'Add-DhTreeFilter','Add-DhNetworkMap',   # DashHtml 2.4.0
        'Add-DhTreeTable'   # DashHtml 2.5.0
    )
    # The content parameter differs per cmdlet; Add-DhTabs carries markup inside
    # its -Tabs hashtables, which is why it is treated as always-needs-review.
    $contentParam = @{
        'Add-DhHtmlBlock'   = 'Content'
        'Add-DhCollapsible' = 'Content'
        'Add-DhAlertBanner' = 'Message'
    }

    # A parameter of a command: its value (literal string or $null), whether the
    # value is a literal, and the extent to replace (the parameter AND its value).
    function Get-DhCallParam {
        param($Command, [string] $Name)
        $els = $Command.CommandElements
        for ($i = 0; $i -lt $els.Count; $i++) {
            $el = $els[$i]
            if ($el -isnot [System.Management.Automation.Language.CommandParameterAst] -or $el.ParameterName -ine $Name) { continue }
            $arg = $el.Argument; $end = $el.Extent.EndOffset
            if (-not $arg -and $i + 1 -lt $els.Count -and $els[$i + 1] -isnot [System.Management.Automation.Language.CommandParameterAst]) {
                $arg = $els[$i + 1]; $end = $arg.Extent.EndOffset
            }
            $isLit = $arg -is [System.Management.Automation.Language.StringConstantExpressionAst]
            # Start of the removable span: the end of the element before it, so a
            # parameter on its own backtick-continued line goes with its line break.
            $prevEnd = if ($i -gt 0) { $els[$i - 1].Extent.EndOffset } else { $el.Extent.StartOffset }
            return [pscustomobject]@{
                Value = if ($isLit) { [string]$arg.Value } else { $null }
                IsLiteral = $isLit
                Start = $el.Extent.StartOffset; End = $end; PrevEnd = $prevEnd
            }
        }
        return $null
    }
    function ConvertTo-DhSq([string] $s) { "'" + $s.Replace("'", "''") + "'" }
    function ConvertTo-DhSeg([string] $s) { $s.Trim().Replace('/', '//') }

    $files = foreach ($p in $Path) {
        if (Test-Path -LiteralPath $p -PathType Container) { Get-ChildItem -LiteralPath $p -Filter *.ps1 -Recurse -File }
        elseif (Test-Path -LiteralPath $p)                 { Get-Item -LiteralPath $p }
        else { Write-Warning "Update-DhScript: not found: $p" }
    }

    $all = [System.Collections.Generic.List[object]]::new()

    foreach ($f in $files) {
        $parseErrors = $null
        $ast = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$parseErrors)
        if ($parseErrors) {
            Write-Warning "Update-DhScript: $($f.Name) has $($parseErrors.Count) parse error(s) - skipped."
            continue
        }

        $srcLines = @(Get-Content -LiteralPath $f.FullName)   # 2.6.0 - @(): a one-line file is a string, and [0] would be its first character
        # Each edit replaces Length characters at Offset with Text (Length 0 = insert).
        $edits    = [System.Collections.Generic.List[object]]::new()
        $addRec = {
            param($Ast, [string] $Cmdlet, [string] $Issue, [bool] $Fixable)
            $all.Add([pscustomobject]@{
                File      = $f.Name
                FullName  = $f.FullName
                Line      = $Ast.Extent.StartLineNumber
                Cmdlet    = $Cmdlet
                Severity  = 'ACTION'
                Issue     = $Issue
                Fixable   = $Fixable
                Source    = $srcLines[$Ast.Extent.StartLineNumber - 1].Trim()
            })
        }

        $commands = @($ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true))
        foreach ($c in $commands) {
            $name = $c.GetCommandName()
            if (-not $name) { continue }
            $params = @($c.CommandElements |
                Where-Object { $_ -is [System.Management.Automation.Language.CommandParameterAst] } |
                ForEach-Object { $_.ParameterName })

            # ---- 3.0.0: -NavGroup / -NavSubGroup removed ----
            if (($name -eq 'Add-DhTable' -or $name -in $blockCmds) -and
                ($params -contains 'NavGroup' -or $params -contains 'NavSubGroup')) {
                $g  = Get-DhCallParam $c 'NavGroup'
                $sg = Get-DhCallParam $c 'NavSubGroup'
                $tid = if ($name -eq 'Add-DhTable') { Get-DhCallParam $c 'TableId' } else { $null }
                $why = $null
                if (-not $g)                                        { $why = '-NavSubGroup without -NavGroup' }
                elseif ($params -contains 'NavPath')                { $why = 'also has -NavPath' }
                elseif (-not $g.IsLiteral -or ($sg -and -not $sg.IsLiteral)) { $why = 'the value is not a literal string' }
                elseif ($name -eq 'Add-DhTable' -and -not ($tid -and $tid.IsLiteral)) { $why = '-TableId is not a literal string' }

                if ($why) {
                    & $addRec $c $name "15.1 -NavGroup/-NavSubGroup removed in 3.0.0; $why - convert to -NavPath by hand" $false
                } else {
                    $segs = @(ConvertTo-DhSeg $g.Value)
                    if ($sg -and $sg.Value) { $segs += ConvertTo-DhSeg $sg.Value }
                    if ($name -eq 'Add-DhTable') { $segs += ConvertTo-DhSeg $tid.Value }
                    $text = '-NavPath ' + (ConvertTo-DhSq ($segs -join '/'))
                    if ($name -ne 'Add-DhTable' -and $params -notcontains 'NavScope') { $text += ' -NavScope Subtree' }
                    $edits.Add(@{ Offset = $g.Start; Length = $g.End - $g.Start; Text = $text })
                    if ($sg) { $edits.Add(@{ Offset = $sg.PrevEnd; Length = $sg.End - $sg.PrevEnd; Text = '' }) }
                    & $addRec $c $name "15.1 -NavGroup/-NavSubGroup removed in 3.0.0 -> $text" $true
                }
            }

            # ---- 2.0.0: -AllowHtml default flipped (1.x scripts) ----
            if ($name -in $htmlCmds -and $params -notcontains 'AllowHtml') {
                # Decide whether this call actually changes behaviour.
                # Add-DhTabs carries its markup inside the -Tabs hashtables, so a
                # missing top-level content parameter proves nothing there.
                $verdict = 'review'
                if ($contentParam.ContainsKey($name)) {
                    $cp = Get-DhCallParam $c $contentParam[$name]
                    if (-not $cp) {
                        # No content parameter at all - e.g. Add-DhCollapsible -Cards,
                        # which is structured and never passes through the encoder.
                        $verdict = 'plain'
                    } elseif ($cp.IsLiteral) {
                        $verdict = if ($cp.Value -match '<[a-zA-Z/!]') { 'markup' } else { 'plain' }
                    }
                }
                if ($verdict -ne 'plain') {
                    if ($verdict -eq 'markup') {
                        # Insert immediately after the command name so we never have to
                        # reason about where the argument list ends.
                        $edits.Add(@{ Offset = $c.CommandElements[0].Extent.EndOffset; Length = 0; Text = ' -AllowHtml:$true' })
                        & $addRec $c $name '15.2 markup will be HTML-ENCODED by the 2.0 default' $true
                    } else {
                        & $addRec $c $name '15.2 -AllowHtml default is $false; content is not a literal - review by hand' $false
                    }
                }
            }
        }

        # ---- 3.0.0: Add-DhBullet 'Bands' key removed ----
        if (@($commands | Where-Object { $_.GetCommandName() -eq 'Add-DhBullet' }).Count) {
            foreach ($h in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.HashtableAst] }, $true)) {
                foreach ($kv in $h.KeyValuePairs) {
                    $k = $kv.Item1
                    if ($k -is [System.Management.Automation.Language.StringConstantExpressionAst] -and $k.Value -ieq 'Bands') {
                        $edits.Add(@{ Offset = $k.Extent.StartOffset; Length = $k.Extent.EndOffset - $k.Extent.StartOffset; Text = 'Thresholds' })
                        & $addRec $k 'Add-DhBullet' "15.3 'Bands' key removed in 3.0.0 -> Thresholds" $true
                    }
                }
            }
        }

        # ---- rewrite ----
        if ($Apply -and $edits.Count -gt 0) {
            if ($PSCmdlet.ShouldProcess($f.FullName, "Apply $($edits.Count) upgrade edit(s)")) {
                $root = Split-Path (Split-Path $f.FullName -Parent) -Parent
                $bkRoot = if ($BackupPath) { $BackupPath } else { Join-Path $root 'Backups' }
                New-Item -ItemType Directory -Path $bkRoot -Force | Out-Null
                $stamp = Get-Date -Format 'yyyyMMdd-HHmmss'
                $bk = Join-Path $bkRoot "$($f.BaseName).ps1.bak-$stamp"
                Copy-Item -LiteralPath $f.FullName -Destination $bk -Force

                # Apply back-to-front so earlier offsets stay valid. The AST offsets
                # count characters of the text the parser read, BOM excluded, which
                # is what ReadAllText returns too.
                $text = [IO.File]::ReadAllText($f.FullName)
                foreach ($e in ($edits | Sort-Object -Property Offset -Descending)) {
                    $text = $text.Substring(0, $e.Offset) + $e.Text + $text.Substring($e.Offset + $e.Length)
                }
                $enc = if ([IO.File]::ReadAllBytes($f.FullName)[0..2] -join ',' -eq '239,187,191') { [Text.UTF8Encoding]::new($true) } else { [Text.UTF8Encoding]::new($false) }
                [IO.File]::WriteAllText($f.FullName, $text, $enc)
                $reErrors = $null
                $null = [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$reErrors)
                if ($reErrors) {
                    Copy-Item -LiteralPath $bk -Destination $f.FullName -Force
                    Write-Warning "Update-DhScript: the rewrite of $($f.Name) did not parse ($($reErrors[0].Message)); the original was restored. Convert it by hand."
                } else {
                    Write-Verbose "Update-DhScript: rewrote $($f.Name) ($($edits.Count) edit(s)); backup at $bk"
                }
            }
        }
    }

    if ($PassThru) { return $all }

    if ($all.Count -eq 0) {
        Write-Host 'Update-DhScript: no upgrade work found.' -ForegroundColor Green
        return
    }

    $all | Sort-Object File, Line | Format-Table File, Line, Cmdlet, Severity, Issue -AutoSize | Out-Host

    Write-Host ''
    Write-Host "ACTION REQUIRED: $($all.Count) item(s)" -ForegroundColor Yellow
    $auto = @($all | Where-Object Fixable)
    if ($auto.Count) {
        Write-Host (" $($auto.Count) can be rewritten automatically" +
                    $(if ($Apply) { ' (applied)' } else { ' - re-run with -Apply' })) -ForegroundColor DarkGray
    }
    $manual = @($all | Where-Object { -not $_.Fixable })
    if ($manual.Count) {
        Write-Host " $($manual.Count) need a human (a value that is not a literal string)" -ForegroundColor DarkGray
    }
    Write-Host ''
    Write-Host 'See readme-dev.md section 15 for the conversion tables.' -ForegroundColor DarkGray
}