Public/Update-DhScript.ps1
|
function Update-DhScript { <# .SYNOPSIS Audit - and optionally rewrite - a DashHtml script for the 2.0.0 upgrade. .DESCRIPTION Parses each .ps1 with the PowerShell AST (it never executes them) and reports every call affected by the 2.0.0 changes. With -Apply it rewrites the file, writing a timestamped backup first. What it looks for: ACTION -AllowHtml default flipped $true -> $false on Add-DhHtmlBlock, Add-DhCollapsible, Add-DhAlertBanner and Add-DhTabs. A call passing markup WITHOUT an explicit -AllowHtml renders that markup as literal text in 2.0.0. advisory Add-DhTable -NavGroup: still works unchanged via the compat shim (it maps to 'Group/TableId'). Reported so you can migrate to -NavPath deliberately, never rewritten automatically. info A block cmdlet with -NavSubGroup was UNREACHABLE in 1.x (readme-dev gotcha #26) and starts rendering in 2.0.0. Content may reappear; nothing to fix. The rewrite is deliberately conservative: it only ever ADDS -AllowHtml:$true to preserve the 1.x appearance, and only when the content is a literal string that actually looks like markup. It never removes a parameter, never touches nav, and never guesses at a non-literal -Content (a variable or expression) - those are reported as needing a human. 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. .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' ) # 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' } $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 $edits = [System.Collections.Generic.List[object]]::new() foreach ($c in $ast.FindAll({ $args[0] -is [System.Management.Automation.Language.CommandAst] }, $true)) { $name = $c.GetCommandName() if (-not $name) { continue } $params = @($c.CommandElements | Where-Object { $_ -is [System.Management.Automation.Language.CommandParameterAst] } | ForEach-Object { $_.ParameterName }) $issue = $null; $sev = 'ACTION'; $fix = $null if ($name -eq 'Add-DhTable' -and $params -contains 'NavGroup') { $issue = '15.1 table uses the v1 -NavGroup compat API' $sev = 'advisory' } elseif ($name -in $blockCmds -and $params -contains 'NavSubGroup') { $issue = '15.3 block in a subgroup - invisible in 1.x, will now render' $sev = 'info' } elseif ($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)) { $pName = $contentParam[$name] $val = $null $found = $false for ($i = 0; $i -lt $c.CommandElements.Count; $i++) { $el = $c.CommandElements[$i] if ($el -is [System.Management.Automation.Language.CommandParameterAst] -and $el.ParameterName -ieq $pName) { $found = $true if ($i + 1 -lt $c.CommandElements.Count) { $next = $c.CommandElements[$i + 1] if ($next -is [System.Management.Automation.Language.StringConstantExpressionAst] -or $next -is [System.Management.Automation.Language.ExpandableStringExpressionAst]) { $val = $next.Value } } break } } if (-not $found) { # No content parameter at all - e.g. Add-DhCollapsible -Cards, # which is structured and never passes through the encoder. # Flagging it would send the reader chasing a non-issue. $verdict = 'plain' } elseif ($null -ne $val) { $verdict = if ($val -match '<[a-zA-Z/!]') { 'markup' } else { 'plain' } } } if ($verdict -eq 'plain') { continue } # behaviour is unchanged $issue = if ($verdict -eq 'markup') { '15.2 markup will be HTML-ENCODED by the new default' } else { '15.2 -AllowHtml default flipped; content is not a literal - review by hand' } $sev = 'ACTION' if ($verdict -eq 'markup') { # Insert immediately after the command name so we never have to # reason about where the argument list ends. $fix = @{ Offset = $c.CommandElements[0].Extent.EndOffset; Text = ' -AllowHtml:$true' } } } if (-not $issue) { continue } $rec = [pscustomobject]@{ File = $f.Name FullName = $f.FullName Line = $c.Extent.StartLineNumber Cmdlet = $name Severity = $sev Issue = $issue Fixable = [bool]$fix Source = $srcLines[$c.Extent.StartLineNumber - 1].Trim() } $all.Add($rec) if ($fix) { $edits.Add($fix) } } # ---- rewrite ---- if ($Apply -and $edits.Count -gt 0) { if ($PSCmdlet.ShouldProcess($f.FullName, "Add -AllowHtml:`$true to $($edits.Count) call(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. $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) } [IO.File]::WriteAllText($f.FullName, $text) 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 2.0.0 upgrade work found.' -ForegroundColor Green return } $all | Sort-Object Severity, File, Line | Format-Table File, Line, Cmdlet, Severity, Issue -AutoSize | Out-Host $action = @($all | Where-Object Severity -eq 'ACTION') Write-Host '' if ($action.Count) { Write-Host "ACTION REQUIRED: $($action.Count) call(s)" -ForegroundColor Yellow $auto = @($action | 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 = @($action | Where-Object { -not $_.Fixable }) if ($manual.Count) { Write-Host " $($manual.Count) need a human (content is not a literal string)" -ForegroundColor DarkGray } } else { Write-Host 'ACTION REQUIRED: none - these scripts upgrade as-is.' -ForegroundColor Green } Write-Host '' Write-Host 'See readme-dev.md section 15 for the conversion tables.' -ForegroundColor DarkGray } |