Tools/Split-AzStackHciModule.ps1

<#
.SYNOPSIS
    One-shot refactor tool: split AzStackHci.ManageUpdates.psm1 into
    Public/<func>.ps1 + Private/<func>.ps1 dot-sourced files.
 
.DESCRIPTION
    Uses the PowerShell AST (NOT regex) to find every top-level
    FunctionDefinitionAst, extracts its exact source extent verbatim,
    and writes one file per function. The original .psm1 is then
    regenerated as a thin shell (prologue + NestedModules dot-source
    fallback + Export-ModuleMember) and the .psd1 is updated with
    a NestedModules list.
 
    Intended to be run ONCE. The script is checked in for audit / replay
    only; routine builds do not need it.
 
    HISTORICAL: This script was used once against AzStackHci.ManageUpdates
    (the monolithic v0.7.2 .psm1). The module has since been renamed to
    AzLocal.UpdateManagement (v0.7.3). The path references inside this
    script ('AzStackHci.ManageUpdates.psm1' / '.psd1') are preserved
    verbatim as an accurate record of what was processed. The script
    is NOT runnable in the current layout - re-running it would target
    files that no longer exist under those names.
 
.NOTES
    Source-of-truth for which functions are public: the manifest's
    FunctionsToExport list. Anything not in that list is treated as
    private.
 
    All file writes use UTF-8 NO BOM via [IO.File]::WriteAllText to
    avoid the cp1252 / mojibake failure modes called out in the user's
    PowerShell-patterns memory.
#>

[CmdletBinding(SupportsShouldProcess)]
param(
    [string]$ModuleRoot = (Join-Path $PSScriptRoot '..'),
    [switch]$DryRun
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$ModuleRoot = (Resolve-Path -LiteralPath $ModuleRoot).Path
$psm1Path   = Join-Path $ModuleRoot 'AzStackHci.ManageUpdates.psm1'
$psd1Path   = Join-Path $ModuleRoot 'AzStackHci.ManageUpdates.psd1'
$publicDir  = Join-Path $ModuleRoot 'Public'
$privateDir = Join-Path $ModuleRoot 'Private'

if (-not (Test-Path -LiteralPath $psm1Path)) { throw "psm1 not found: $psm1Path" }
if (-not (Test-Path -LiteralPath $psd1Path)) { throw "psd1 not found: $psd1Path" }

$utf8NoBom = [System.Text.UTF8Encoding]::new($false)

# --- 1. Parse manifest to get the authoritative public-function list ---
$manifest = Import-PowerShellDataFile -LiteralPath $psd1Path
$publicNames = @($manifest.FunctionsToExport) | Where-Object { $_ -and $_ -ne '*' }
if (-not $publicNames -or $publicNames.Count -eq 0) {
    throw "Manifest FunctionsToExport is empty or wildcard; cannot determine public set."
}
Write-Host ("[manifest] {0} public function(s) declared in FunctionsToExport" -f $publicNames.Count)

# --- 2. Parse the psm1 with the PowerShell AST ---
$tokens    = $null
$parseErrs = $null
$ast = [System.Management.Automation.Language.Parser]::ParseFile($psm1Path, [ref]$tokens, [ref]$parseErrs)
if ($parseErrs -and $parseErrs.Count -gt 0) {
    $parseErrs | ForEach-Object { Write-Warning $_ }
    throw "Parser reported $($parseErrs.Count) errors. Aborting."
}

# Read raw text once for extent-slicing.
$rawText = [IO.File]::ReadAllText($psm1Path, $utf8NoBom)

# Find top-level function definitions only (not nested inside other functions / classes).
$allFnAst = $ast.FindAll({
        param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst]
    }, $true)
$topFns = @($allFnAst | Where-Object {
        $p = $_.Parent
        while ($p -and -not ($p -is [System.Management.Automation.Language.ScriptBlockAst] -and $p.Parent -is [System.Management.Automation.Language.ScriptBlockAst] -eq $false)) {
            if ($p -is [System.Management.Automation.Language.FunctionDefinitionAst]) { return $false }
            $p = $p.Parent
        }
        # Top-level: its closest function-ancestor must be itself
        $cur = $_.Parent
        while ($cur) {
            if ($cur -is [System.Management.Automation.Language.FunctionDefinitionAst]) { return $false }
            $cur = $cur.Parent
        }
        return $true
    })

Write-Host ("[ast] {0} top-level function definitions found" -f $topFns.Count)

# --- 3. Sort, capture inter-function NON-function statements ---
# IMPORTANT: a previous version of this script only collected FunctionDefinitionAst nodes,
# which silently dropped 6 non-function top-level statements - notably the
# $script:DayAbbreviations / $script:UpdateWindowTagName / $script:UpdateSideloadedTagName
# / $script:UpdateVersionInProgressTagName / $script:DayMap / $script:FleetOperationState
# initialisers that were defined BETWEEN function definitions in the monolithic .psm1.
# We now walk EndBlock.Statements and segregate functions from everything else, then
# fold the non-function statements back into the prologue text so module-scope state
# survives the refactor.
$topFns = $topFns | Sort-Object { $_.Extent.StartLineNumber }
$first  = $topFns[0]
$last   = $topFns[-1]
Write-Host ("[range] first function: L{0} {1}" -f $first.Extent.StartLineNumber, $first.Name)
Write-Host ("[range] last function: L{0} {1}" -f $last.Extent.EndLineNumber,   $last.Name)

# Inter-function and post-function top-level statements (everything in EndBlock.Statements
# that is NOT a function). We keep their VERBATIM source via Extent.Text.
$allStmts   = @($ast.EndBlock.Statements)
$nonFnStmts = @($allStmts | Where-Object { -not ($_ -is [System.Management.Automation.Language.FunctionDefinitionAst]) })
Write-Host ("[ast] {0} non-function top-level statements (will be hoisted into .psm1)" -f $nonFnStmts.Count)

# Build the prologue from line-based slicing of the original text so we preserve
# every comment, blank line, and `Set-StrictMode` / `Requires` directive exactly.
$prologueLines = $rawText -split "`r?`n"
$preFirstFn    = ($prologueLines[0..($first.Extent.StartLineNumber - 2)] -join "`r`n") + "`r`n"

# Gather any non-function statements that appear AFTER the first function but BEFORE the last.
# These go into a single "hoisted module-scope state" block in the prologue so they are
# defined before any dot-sourced function runs.
$interFnStmts = @($nonFnStmts | Where-Object { $_.Extent.StartLineNumber -gt $first.Extent.StartLineNumber -and $_.Extent.EndLineNumber -lt $last.Extent.EndLineNumber })
if ($interFnStmts.Count -gt 0) {
    $hoistBlock  = "`r`n# ---------------------------------------------------------------------------`r`n"
    $hoistBlock += "# Module-scope state hoisted from between function definitions during refactor.`r`n"
    $hoistBlock += "# These declarations must run BEFORE any function body that references them.`r`n"
    $hoistBlock += "# ---------------------------------------------------------------------------`r`n"
    foreach ($s in $interFnStmts) {
        $hoistBlock += ($s.Extent.Text.TrimEnd() + "`r`n`r`n")
    }
} else {
    $hoistBlock = ''
}

# Anything AFTER the last function (e.g. the closing Export-ModuleMember) becomes the epilogue.
$postLastFnStmts = @($nonFnStmts | Where-Object { $_.Extent.StartLineNumber -gt $last.Extent.EndLineNumber })
$epilogue = if ($postLastFnStmts.Count -gt 0) {
    ($postLastFnStmts | ForEach-Object { $_.Extent.Text.TrimEnd() }) -join "`r`n`r`n"
} else { '' }

$prologue = $preFirstFn + $hoistBlock
Write-Host ("[prologue] {0} bytes" -f $prologue.Length)
Write-Host ("[epilogue] {0} bytes ({1} statements)" -f $epilogue.Length, $postLastFnStmts.Count)

# Compose the new thin psm1 body. We use a deterministic dot-source loader keyed
# off the manifest's NestedModules list - this matches AzLocal.DeploymentAutomation's pattern.
$loader = @'
 
# ---------------------------------------------------------------------------
# Dot-source all function files listed in the manifest's NestedModules.
# When loaded via the .psd1, these are already imported by NestedModules; this
# loop is a harmless no-op in that case (PowerShell tolerates redefinition).
# When loaded via the .psm1 directly (e.g. some Pester scenarios), this
# guarantees all functions are present in the module scope.
# ---------------------------------------------------------------------------
$manifestPath = Join-Path -Path $PSScriptRoot -ChildPath 'AzStackHci.ManageUpdates.psd1'
if (Test-Path -LiteralPath $manifestPath) {
    $manifestData = Import-PowerShellDataFile -LiteralPath $manifestPath -ErrorAction SilentlyContinue
    if ($manifestData -and $manifestData.NestedModules) {
        foreach ($nestedModule in $manifestData.NestedModules) {
            $nestedPath = Join-Path -Path $PSScriptRoot -ChildPath $nestedModule
            if (Test-Path -LiteralPath $nestedPath) {
                . $nestedPath
            }
        }
    }
}
'@


# --- 4. Decide destination for each function and build file content ---
$publicSet = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($n in $publicNames) { [void]$publicSet.Add($n) }

$plans = New-Object System.Collections.Generic.List[object]
foreach ($fn in $topFns) {
    $isPublic = $publicSet.Contains($fn.Name)
    $destDir  = if ($isPublic) { 'Public' } else { 'Private' }
    $destPath = Join-Path (Join-Path $ModuleRoot $destDir) ($fn.Name + '.ps1')
    # Use Extent.Text - returns the verbatim source text for this AST node, no offset math.
    # (Avoids CRLF/LF offset-shift bugs in some PowerShell versions where StartOffset is reported
    # in LF-normalized space but our raw read is CRLF.)
    $body     = $fn.Extent.Text
    $plans.Add([pscustomobject]@{
        Name      = $fn.Name
        IsPublic  = $isPublic
        DestPath  = $destPath
        RelPath   = ((Join-Path $destDir ($fn.Name + '.ps1')) -replace '\\','/')
        Body      = $body
        StartLine = $fn.Extent.StartLineNumber
        EndLine   = $fn.Extent.EndLineNumber
    })
}

$pubCount = ($plans | Where-Object IsPublic).Count
$prvCount = ($plans | Where-Object { -not $_.IsPublic }).Count
Write-Host ("[plan] {0} public + {1} private = {2} total files" -f $pubCount, $prvCount, $plans.Count)

# Sanity check: every name in FunctionsToExport must have a matching function defined.
$definedNames = @($plans | Select-Object -ExpandProperty Name)
$missing = @($publicNames | Where-Object { $_ -notin $definedNames })
if ($missing.Count -gt 0) {
    throw ("Public names in manifest with no function definition in psm1: {0}" -f ($missing -join ', '))
}

# Detect duplicates (would clobber files)
$dupes = $plans | Group-Object Name | Where-Object Count -gt 1
if ($dupes) {
    throw ("Duplicate function definitions detected: {0}" -f (($dupes | ForEach-Object Name) -join ', '))
}

if ($DryRun) {
    Write-Host "[dry-run] Plan summary:"
    $plans | Sort-Object IsPublic, Name -Descending | Select-Object @{n='Where';e={if($_.IsPublic){'Public'}else{'Private'}}}, Name, StartLine, EndLine | Format-Table -AutoSize
    return
}

# --- 5. Write Public/ and Private/ files ---
foreach ($dir in @($publicDir, $privateDir)) {
    if (-not (Test-Path -LiteralPath $dir)) {
        [void](New-Item -ItemType Directory -Path $dir)
    }
}

foreach ($p in $plans) {
    # Trim leading blank lines so the file starts on the `function` keyword cleanly,
    # and ensure exactly one trailing newline.
    $content = $p.Body -replace "^\s*\r?\n",""
    if ($content[-1] -ne "`n") { $content += "`r`n" }
    [IO.File]::WriteAllText($p.DestPath, $content, $utf8NoBom)
}
Write-Host ("[write] wrote {0} function file(s)" -f $plans.Count)

# --- 6. Regenerate the .psm1 ---
$newPsm1 = $prologue.TrimEnd() + "`r`n" + $loader + "`r`n" + $epilogue.TrimStart()
[IO.File]::WriteAllText($psm1Path, $newPsm1, $utf8NoBom)
Write-Host ("[write] regenerated {0} ({1} bytes)" -f $psm1Path, $newPsm1.Length)

# --- 7. Update the .psd1 NestedModules list ---
# Build the new NestedModules list: Private/ first (alphabetical), then Public/ (alphabetical).
$privList = $plans | Where-Object { -not $_.IsPublic } | Sort-Object Name | ForEach-Object { $_.RelPath }
$pubList  = $plans | Where-Object IsPublic           | Sort-Object Name | ForEach-Object { $_.RelPath }
$nestedAll = @($privList) + @($pubList)

# Render the NestedModules list as a PowerShell array literal that fits the existing manifest style.
$indent = ' '
$rendered = "@(`r`n"
$rendered += " # Private helpers (loaded first)`r`n"
foreach ($r in $privList) { $rendered += "$indent'" + $r + "',`r`n" }
$rendered += "`r`n # Public exported functions`r`n"
for ($i = 0; $i -lt $pubList.Count; $i++) {
    $sep = if ($i -lt $pubList.Count - 1) { "',`r`n" } else { "'`r`n" }
    $rendered += "$indent'" + $pubList[$i] + $sep
}
$rendered += " )"

$psd1Text = [IO.File]::ReadAllText($psd1Path, $utf8NoBom)

# Find existing NestedModules entry (if any). We allow:
# NestedModules = @()
# NestedModules = @('a','b')
# # NestedModules = @(...) <- commented out (current state)
# Replace the first uncommented occurrence; if none, inject before FunctionsToExport.
$nmRegex = [regex]'(?ms)^\s*NestedModules\s*=\s*@\([^\)]*\)'
$commentedNmRegex = [regex]'(?m)^\s*#\s*NestedModules\s*=\s*@\(\s*\)\s*$'

if ($nmRegex.IsMatch($psd1Text)) {
    $psd1Text = $nmRegex.Replace($psd1Text, " NestedModules = $rendered", 1)
    Write-Host "[psd1] replaced existing uncommented NestedModules entry"
} elseif ($commentedNmRegex.IsMatch($psd1Text)) {
    $psd1Text = $commentedNmRegex.Replace($psd1Text, " NestedModules = $rendered", 1)
    Write-Host "[psd1] replaced commented-out NestedModules placeholder"
} else {
    # Inject just before FunctionsToExport block
    $injectRegex = [regex]'(?m)^(\s*)FunctionsToExport\s*='
    if (-not $injectRegex.IsMatch($psd1Text)) { throw "Could not find FunctionsToExport in manifest" }
    $psd1Text = $injectRegex.Replace($psd1Text, " NestedModules = $rendered`r`n`r`n`$1FunctionsToExport =", 1)
    Write-Host "[psd1] injected new NestedModules entry before FunctionsToExport"
}
[IO.File]::WriteAllText($psd1Path, $psd1Text, $utf8NoBom)
Write-Host "[done] Refactor complete. Run Pester to verify."