CI/PS-CI.ps1

[cmdletbinding(DefaultParameterSetName='Scope')]
Param(
    [Parameter(Mandatory = $true, ParameterSetName = 'ModulePath')]
    [ValidateNotNullOrEmpty()]
    [String]$ModulePath,

    # Path to install the module to, PSModulePath "CurrentUser" or "AllUsers", if not provided "CurrentUser" used.
    [Parameter(ParameterSetName = 'Scope')]
    [ValidateSet('CurrentUser', 'AllUsers')]
    [string]
    $Scope = 'CurrentUser',

    [Parameter(Mandatory=$true, ParameterSetName = 'PreCheckOnly')]
    [switch]$PreCheckOnly,
    [Parameter(ParameterSetName = 'ModulePath')]
    [Parameter(ParameterSetName = 'Scope')]
    [switch]$SkipPreChecks,
    [Parameter(ParameterSetName = 'ModulePath')]
    [Parameter(ParameterSetName = 'Scope')]
    [switch]$SkipPostChecks,
    [Parameter(ParameterSetName = 'ModulePath')]
    [Parameter(ParameterSetName = 'Scope')]
    [switch]$SkipPesterTests,
    [Parameter(ParameterSetName = 'ModulePath')]
    [Parameter(ParameterSetName = 'Scope')]
    [switch]$SkipHelp,
    [Parameter(ParameterSetName = 'ModulePath')]
    [Parameter(ParameterSetName = 'Scope')]
    [switch]$CleanModuleDir
)
Function Show-Warning {
    param(
        [Parameter(Position=0,ValueFromPipeline=$true)]
        $message
    )
    process {
        write-output "##vso[task.logissue type=warning]File $message"
        $message >> $script:warningfile
    }
}

if ($PSScriptRoot) {
    $workingdir = Split-Path -Parent $PSScriptRoot
    Push-Location $workingdir
}
$psdpath = Get-Item "*.psd1"
if (-not $psdpath -or $psdpath.count -gt 1) {
    if ($PSScriptRoot) { Pop-Location }
    throw "Did not find a unique PSD file "
}
else {
    try   {$null        = Test-ModuleManifest -Path $psdpath -ErrorAction stop}
    catch {throw $_ ; return}
    $ModuleName         = $psdpath.Name -replace '\.psd1$' , ''
    $Settings           = $(& ([scriptblock]::Create(($psdpath | Get-Content -Raw))))
    $approvedVerbs      = Get-Verb | Select-Object -ExpandProperty verb
    $script:warningfile = Join-Path -Path $pwd -ChildPath "warnings.txt"
}

#pre-build checks - manifest found, files in it found, public functions and aliases loaded in it. Public functions correct.
if (-not $SkipPreChecks) {

    #Check files in the manifest are present
    foreach ($file in $Settings.FileList) {
        if (-not (Test-Path $file)) {
           Show-Warning "File $file in the manifest file list is not present"
        }
    }

    #Check files in public have Approved_verb-noun names and are 1 function using the file name as its name with
    # its name and any alias names in the manifest; function should have a param block and help should be in an MD file
    # We will want a regex which captures from "function verb-noun {" to its closing "}"
    # need to match each { to a } - $reg is based on https://stackoverflow.com/questions/7898310/using-regex-to-balance-match-parenthesis
    $reg  = [Regex]::new(@"
        function\s*[-\w]+\s*{ # The function name and opening '{'
            (?:
            [^{}]+ # Match all non-braces
            |
            (?<open> { ) # Match '{', and capture into 'open'
            |
            (?<-open> } ) # Match '}', and delete the 'open' capture
            )*
            (?(open)(?!)) # Fails if 'open' stack isn't empty
        } # Functions closing '}'
"@
, 57)  # 57 = compile,multi-line ignore case and white space.
    foreach ($file in (Get-Item .\Public\*.ps1)) {
        $name = $file.name -replace(".ps1","")
        if ($name -notmatch ("(\w+)-\w+"))         {Show-Warning "$name in the public folder is not a verb-noun name"}
        elseif ($Matches[1] -notin $approvedVerbs) {Show-Warning "$name in the public folder does not start with an approved verb"}
        if(-not ($Settings.FunctionsToExport -ceq $name)) {
            Show-Warning ('File {0} in the public folder does not match an exported function in the manifest' -f $file.name)
        }
        else {
            $fileContent = Get-Content $file -Raw
            $m    = $reg.Matches($fileContent)
            if     ($m.Count -eq 0)                         {Show-Warning ('Could not find {0} function in {1}'  -f $name, $file.name); continue}
            elseif ($m.Count -ge 2)                         {Show-Warning ('Multiple functions in {0}' -f $item.name)         ; Continue}
            elseif ($m[0] -imatch "^\function\s" -and
                    $m[0] -cnotmatch "^\w+\s+$name")        {Show-Warning ('function name does not match file name for {0}' -f $file.name)}
            #$m[0] runs form the f of function to its final } -find the section up to param, check for aliases & comment-based help
            $m2 = [regex]::Match($m[0],"^.*?param",17) # 17 = multi-line, ignnore case
            if (-not $m2.Success)                           {Show-Warning "function $name has no param() block"}
            else {
                if ($m2.value -match "(?<!#\s*)\[\s*Alias\(\s*.([\w-]+).\s*\)\s*\]") {
                    foreach ($a in  ($Matches[1] -split '\s*,\s*')) {
                        $a = $a -replace "'",""  -replace '"',''
                        if (-not ($Settings.AliasesToExport   -eq $a)) {
                            Show-Warning "Function $name has alias $a which is not in the manifest"
                        }
                     }
                }
                if ($m2.value -match "\.syopsis|\.Description|\.Example") {
                            Show-Warning "Function $name appears to have comment based help."
                }
            }
        }
    }

    #Warn about functions which are exported but not found in public
    $notFromPublic = $Settings.FunctionsToExport.Where({-not (Test-Path ".\public\$_.ps1")})
    If ($notFromPublic) {Show-Warning ('Exported function(s) {0} are not loaded from the Public folder' -f ($notFromPublic -join ', '))}
}

if ($PreCheckOnly) {return}

#region build, determine module path if necessary, create target directory if necessary, copy files based on manifest, build help
try     {
    if ($ModulePath) {
        $ModulePath = $ModulePath -replace "\\$|/$",""
    }
    else {
        if ($IsLinux -or $IsMacOS)      {$ModulePathSeparator = ':' }
        else                            {$ModulePathSeparator = ';' }
        if ($Scope   -eq 'CurrentUser') {$dir =  [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::UserProfile) }
        else                            {$dir =  [System.Environment]::GetFolderPath([System.Environment+SpecialFolder]::ProgramFiles) }
        $ModulePath = ($env:PSModulePath -split $ModulePathSeparator).where({$_ -like "$dir*"},"First",1)
        $ModulePath = Join-Path -Path $ModulePath -ChildPath $ModuleName
        $ModulePath = Join-Path -Path $ModulePath -ChildPath $Settings.ModuleVersion
    }
    # Clean-up / Create Directory
    if (-not  (Test-Path -Path $ModulePath)) {
        $null = New-Item -Path $ModulePath -ItemType Directory -ErrorAction Stop
        'Created module folder: "{0}"' -f $ModulePath
    }
    elseif ($CleanModuleDir) {
        '{0} exists - cleaning before copy' -f $ModulePath
        Get-ChildItem -Path $ModulePath | Remove-Item -Force -Recurse
    }
    'Copying files to: "{0}"' -f $ModulePath
    $outputFile = $psdpath | Copy-Item -Destination $ModulePath -PassThru
    $outputFile.fullname
    foreach ($file in $Settings.FileList) {
        if  ($file -like '.\*') {
             $dest = ($file -replace '\.\\',"$ModulePath\")
             if (-not (Test-Path -PathType Container (Split-Path -Parent $dest))) {
                $null = New-item -Type Directory -Path (Split-Path -Parent $dest)
             }
        }
        else  {$dest = $ModulePath }
        Copy-Item -Path $file  -Destination $dest -Force -Recurse
    }

    if ((Test-Path -PathType Container "mdHelp") -and -not $SkipHelp) {
        if (-not (Get-Module -ListAvailable platyPS)) {
            'Installing Platyps to build help files'
            Install-Module -Name platyPS -Force -SkipPublisherCheck
        }
        $platypsInfo = Import-Module platyPS  -PassThru -force
        Get-ChildItem .\mdHelp -Directory | ForEach-Object {
           'Building help for language ''{0}'', using {1} V{2}.' -f $_.Name,$platypsInfo.Name, $platypsInfo.Version
            $Null = New-ExternalHelp -Path $_.FullName  -OutputPath (Join-Path $ModulePath $_.Name) -Force
        }
    }
    #Leave module path for things which follow.
    $env:PSNewBuildModule = $ModulePath
}
catch   {
    if ($PSScriptRoot) { Pop-Location }
    throw ('Failed installing module "{0}". Error: "{1}" in Line {2}' -f $ModuleName, $_, $_.InvocationInfo.ScriptLineNumber)
}
finally {
    if (-not $outputFile -or -not (Test-Path $outputFile)) { throw "Failed to create module"}
}
#endregion

if ($env:Build_ArtifactStagingDirectory) {
    Copy-Item -Path (split-path -Parent $ModulePath) -Destination $env:Build_ArtifactStagingDirectory -Recurse
}

#Check valid command names, help, run script analyzer over the files in the module directory
if (-not $SkipPostChecks) {
    try   {$outputFile | Import-Module -Force -ErrorAction stop }
    catch {
            if ($PSScriptRoot) { Pop-Location }
            throw "New module failed to load"
    }
    $commands = Get-Command -Module $ModuleName -CommandType function,Cmdlet
    $commands.where({$_.name -notmatch "(\w+)-\w+" -or $Matches[1] -notin $approvedVerbs}) | ForEach-Object {
        Show-Warning ('{0} does not meet the ApprovedVerb-Noun naming rules' -f $_.name)
    }
    $helpless = $commands | Get-Help | Where-Object {$_.Synopsis -match "^\s+$($_.name)\s+\["} | Select-Object -ExpandProperty name
    foreach ($command in $helpless ) {
        Show-Warning ('On-line help is missing for {0}.' -f $command)
    }
    if (-not (Get-Module -Name PSScriptAnalyzer -ListAvailable)) {
        Install-Module -Name PSScriptAnalyzer -Force
    }
    $PSSAInfo = Import-module -Name PSScriptAnalyzer  -PassThru -force
    "Running {1} V{2} against '{0}' " -f $ModulePath , $PSSAInfo.name, $PSSAInfo.Version
    $AnalyzerResults = Invoke-ScriptAnalyzer -Path $ModulePath -Recurse -ErrorAction SilentlyContinue
    if ($AnalyzerResults) {
        if (-not (Get-Module -Name ImportExcel -ListAvailable)) {
            #ironically we use this to build import-excel Shouldn't need this there!
            'Installing ImportExcel.'
            Install-Module -Name ImportExcel -Force
        }
        $chartDef = New-ExcelChartDefinition -ChartType 'BarClustered' -Column 2 -Title "Script analysis" -LegendBold
        $ExcelParams = @{
            Path                 = (Join-Path $pwd  'ScriptAnalyzer.xlsx')
            WorksheetName        = 'FullResults'
            TableStyle           = 'Medium6'
            AutoSize             = $true
            Activate             = $true
            PivotTableDefinition = @{BreakDown = @{
                PivotData            = @{RuleName = 'Count' }
                PivotRows            = 'Severity', 'RuleName'
                PivotTotals          = 'Rows'
                PivotChartDefinition = $chartDef }}
        }
        Remove-Item -Path $ExcelParams['Path'] -ErrorAction SilentlyContinue
        $AnalyzerResults | Export-Excel @ExcelParams
        if (Test-Path $ExcelParams['Path']) {
            "Try to uploadfile {0}" -f $ExcelParams['Path']
            "##vso[task.uploadfile]{0}" -f $ExcelParams['Path']
        }
    }
}

if (Test-Path $script:warningfile) {
    "Try to uploadfile {0}" -f $script:warningfile
    "##vso[task.uploadfile]{0}" -f $script:warningfile
}

#if there are test files, run pester (unless told not to)
if (-not $SkipPesterTests -and (Get-ChildItem -Recurse *.tests.ps1)) {
    Import-Module -Force $outputFile
    if (-not (Get-Module -ListAvailable pester | Where-Object -Property version -ge ([version]::new(4,4,1)))) {
        Install-Module Pester -Force -SkipPublisherCheck
    }
    Import-Module Pester
    $PesterOutputPath = Join-Path $pwd  -ChildPath ('TestResultsPS{0}.xml' -f $PSVersionTable.PSVersion)
    if ($PSScriptRoot) { Pop-Location }
    Invoke-Pester -OutputFile $PesterOutputPath
}
elseif ($PSScriptRoot) { Pop-Location }