tools/Build-PrivateUnitTests.ps1

<#
.SYNOPSIS
Generate Pester 3 contract tests for every private GitEasy helper.

.DESCRIPTION
Writes one Tests\Unit\<HelperName>.Tests.ps1 file per private helper. Because private helpers are not exported from the module, the generated tests use AST parsing of the helper's source file rather than Get-Command -Module. Each test asserts the helper's contract: the source file declares a function with the matching name, the function has [CmdletBinding()], the parameters are declared, switch parameters are typed correctly, validate-set values are present, and the function ships comment-based help (.SYNOPSIS at minimum).

The generated files are deterministic - re-running this script regenerates them in place. Hand edits will be overwritten.

.PARAMETER ProjectRoot
Absolute path to the GitEasy source repository. Defaults to C:\Sysadmin\Scripts\GitEasy.

.EXAMPLE
.\tools\Build-PrivateUnitTests.ps1
#>


[CmdletBinding()]
param(
    [string]$ProjectRoot = 'C:\Sysadmin\Scripts\GitEasy'
)

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

$privateRoot = Join-Path $ProjectRoot 'Private'
$unitRoot    = Join-Path $ProjectRoot 'Tests\Unit'

if (-not (Test-Path -LiteralPath $privateRoot -PathType Container)) {
    throw "Missing Private folder: $privateRoot"
}

if (-not (Test-Path -LiteralPath $unitRoot -PathType Container)) {
    New-Item -Path $unitRoot -ItemType Directory -Force | Out-Null
}

$privateFiles = @(Get-ChildItem -LiteralPath $privateRoot -Filter '*.ps1' -File | Sort-Object Name)

$generated = 0

foreach ($file in $privateFiles) {
    $helperName = $file.BaseName
    $tokens = $null; $errors = $null
    $ast = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$errors)
    $fnAst = @($ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) | Select-Object -First 1

    if (-not $fnAst) {
        Write-Warning "Skipping $helperName - no function definition found."
        continue
    }

    $declaredParams = @()
    if ($fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Parameters) {
        foreach ($p in $fnAst.Body.ParamBlock.Parameters) {
            $name = $p.Name.VariablePath.UserPath
            $type = if ($p.StaticType) { $p.StaticType.Name } else { '' }
            $isMandatory = $false
            $validateSet = @()
            foreach ($attr in $p.Attributes) {
                if ($attr -is [System.Management.Automation.Language.AttributeAst]) {
                    if ($attr.TypeName.Name -eq 'Parameter') {
                        foreach ($na in $attr.NamedArguments) {
                            if ($na.ArgumentName -eq 'Mandatory') { $isMandatory = $true }
                        }
                    }
                    if ($attr.TypeName.Name -eq 'ValidateSet') {
                        foreach ($pa in $attr.PositionalArguments) {
                            if ($pa -is [System.Management.Automation.Language.StringConstantExpressionAst]) {
                                $validateSet += $pa.Value
                            }
                        }
                    }
                }
            }
            $declaredParams += [PSCustomObject]@{
                Name        = $name
                Type        = $type
                Mandatory   = $isMandatory
                IsSwitch    = ($type -eq 'SwitchParameter')
                ValidateSet = $validateSet
            }
        }
    }

    $hasCmdletBinding = $false
    if ($fnAst.Body.ParamBlock -and $fnAst.Body.ParamBlock.Attributes) {
        foreach ($attr in $fnAst.Body.ParamBlock.Attributes) {
            if ($attr -is [System.Management.Automation.Language.AttributeAst] -and $attr.TypeName.Name -eq 'CmdletBinding') {
                $hasCmdletBinding = $true
            }
        }
    }

    $relativePath = ('Private\' + $file.Name)

    $lines = New-Object System.Collections.Generic.List[string]
    $lines.Add('# Generated by tools\Build-PrivateUnitTests.ps1. Re-run to regenerate.')
    $lines.Add('')
    $lines.Add('$ProjectRoot = Split-Path -Parent (Split-Path -Parent $PSScriptRoot)')
    $lines.Add('$RelativePath = ''' + $relativePath + '''')
    $lines.Add('$SourcePath = Join-Path $ProjectRoot $RelativePath')
    $lines.Add('')
    $lines.Add("Describe '$helperName (private contract)' {")
    $lines.Add('')
    $lines.Add(' BeforeAll {')
    $lines.Add(' $script:Ast = $null')
    $lines.Add(' $script:Fn = $null')
    $lines.Add(' if (Test-Path -LiteralPath $SourcePath -PathType Leaf) {')
    $lines.Add(' $tokens = $null; $errors = $null')
    $lines.Add(' $script:Ast = [System.Management.Automation.Language.Parser]::ParseFile($SourcePath, [ref]$tokens, [ref]$errors)')
    $lines.Add(' $script:Fn = @($script:Ast.FindAll({ param($n) $n -is [System.Management.Automation.Language.FunctionDefinitionAst] }, $true)) | Select-Object -First 1')
    $lines.Add(' }')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'source file exists at the expected location' {")
    $lines.Add(' Test-Path -LiteralPath $SourcePath -PathType Leaf | Should Be $true')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'declares a function whose name matches the file name' {")
    $lines.Add(' $script:Fn.Name | Should Be ''' + $helperName + '''')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add(" It 'name uses the GE prefix convention' {")
    $lines.Add(' $script:Fn.Name | Should Match ''^[A-Za-z]+-GE[A-Za-z0-9]+$''')
    $lines.Add(' }')
    $lines.Add('')

    if ($hasCmdletBinding) {
        $lines.Add(" It 'declares [CmdletBinding()]' {")
        $lines.Add(' $hasBinding = $false')
        $lines.Add(' if ($script:Fn.Body.ParamBlock -and $script:Fn.Body.ParamBlock.Attributes) {')
        $lines.Add(' foreach ($attr in $script:Fn.Body.ParamBlock.Attributes) {')
        $lines.Add(' if ($attr.TypeName.Name -eq ''CmdletBinding'') { $hasBinding = $true }')
        $lines.Add(' }')
        $lines.Add(' }')
        $lines.Add(' $hasBinding | Should Be $true')
        $lines.Add(' }')
        $lines.Add('')
    }

    foreach ($p in $declaredParams) {
        $lines.Add(" It 'declares the -$($p.Name) parameter' {")
        $lines.Add(' $names = @()')
        $lines.Add(' if ($script:Fn.Body.ParamBlock -and $script:Fn.Body.ParamBlock.Parameters) {')
        $lines.Add(' $names = @($script:Fn.Body.ParamBlock.Parameters | ForEach-Object { $_.Name.VariablePath.UserPath })')
        $lines.Add(' }')
        $lines.Add(' $names -contains ''' + $p.Name + ''' | Should Be $true')
        $lines.Add(' }')
        $lines.Add('')

        if ($p.IsSwitch) {
            $lines.Add(" It '-$($p.Name) is a switch parameter' {")
            $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1')
            $lines.Add(' $param.StaticType.Name | Should Be ''SwitchParameter''')
            $lines.Add(' }')
            $lines.Add('')
        }

        if ($p.Mandatory) {
            $lines.Add(" It '-$($p.Name) is mandatory' {")
            $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1')
            $lines.Add(' $isMandatory = $false')
            $lines.Add(' foreach ($attr in $param.Attributes) {')
            $lines.Add(' if ($attr.TypeName.Name -eq ''Parameter'') {')
            $lines.Add(' foreach ($na in $attr.NamedArguments) {')
            $lines.Add(' if ($na.ArgumentName -eq ''Mandatory'') { $isMandatory = $true }')
            $lines.Add(' }')
            $lines.Add(' }')
            $lines.Add(' }')
            $lines.Add(' $isMandatory | Should Be $true')
            $lines.Add(' }')
            $lines.Add('')
        }

        if ($p.ValidateSet.Count -gt 0) {
            $lines.Add(" It '-$($p.Name) accepts only the documented values' {")
            $lines.Add(' $param = $script:Fn.Body.ParamBlock.Parameters | Where-Object { $_.Name.VariablePath.UserPath -eq ''' + $p.Name + ''' } | Select-Object -First 1')
            $lines.Add(' $values = @()')
            $lines.Add(' foreach ($attr in $param.Attributes) {')
            $lines.Add(' if ($attr.TypeName.Name -eq ''ValidateSet'') {')
            $lines.Add(' foreach ($pa in $attr.PositionalArguments) {')
            $lines.Add(' if ($pa.Value) { $values += $pa.Value }')
            $lines.Add(' }')
            $lines.Add(' }')
            $lines.Add(' }')
            foreach ($v in $p.ValidateSet) {
                $lines.Add(' $values -contains ''' + $v + ''' | Should Be $true')
            }
            $lines.Add(' }')
            $lines.Add('')
        }
    }

    $lines.Add(" It 'has comment-based help with .SYNOPSIS' {")
    $lines.Add(' $body = Get-Content -LiteralPath $SourcePath -Raw')
    $lines.Add(' $hasCbh = ($body -match ''(?ms)<#.*?\.SYNOPSIS\s+\S.*?#>'')')
    $lines.Add(' $hasCbh | Should Be $true')
    $lines.Add(' }')
    $lines.Add('')
    $lines.Add('}')

    $body = ($lines.ToArray() -join "`r`n") + "`r`n"
    $outPath = Join-Path $unitRoot ($helperName + '.Tests.ps1')
    [System.IO.File]::WriteAllText($outPath, $body, [System.Text.UTF8Encoding]::new($false))
    $generated++
}

Write-Host ''
Write-Host "Generated $generated private unit-test files in $unitRoot" -ForegroundColor Green