PSQualityCheck.psm1

function Invoke-PSQualityCheck {
    <#
        .SYNOPSIS
        Invoke the PSQualityCheck tests
 
        .DESCRIPTION
        Invoke a series of Pester-based quality tests on the passed files
 
        .PARAMETER Path
        A string array containing paths to check for testable files
 
        .PARAMETER File
        A string array containing testable files
 
        .PARAMETER Recurse
        A switch specifying whether or not to recursively search the path specified
 
        .PARAMETER ScriptAnalyzerRulesPath
        A path the the external PSScriptAnalyzer rules
 
        .PARAMETER ShowCheckResults
        Show a summary of the Check results at the end of processing
 
        .PARAMETER ExportCheckResults
        Exports the Check results at the end of processing to file
 
        .PARAMETER Passthru
        Returns the Check results objects back to the caller
 
        .PARAMETER PesterConfiguration
        A Pester configuration object to allow configuration of Pester
 
        .PARAMETER Include
        An array of test tags to run
 
        .PARAMETER Exclude
        An array of test tags to not run
 
        .EXAMPLE
        Invoke-PSQualityCheck -Path 'C:\Scripts'
 
        This will call the quality checks on single path
 
        .EXAMPLE
        Invoke-PSQualityCheck -Path 'C:\Scripts' -Recurse
 
        This will call the quality checks on single path and sub folders
 
        .EXAMPLE
        Invoke-PSQualityCheck -Path @('C:\Scripts', 'C:\MoreScripts')
 
        This will call the quality checks with multiple paths
 
        .EXAMPLE
        Invoke-PSQualityCheck -File 'C:\Scripts\Script.ps1'
 
        This will call the quality checks with single script file
 
        .EXAMPLE
        Invoke-PSQualityCheck -File 'C:\Scripts\Script.psm1'
 
        This will call the quality checks with single module file
 
        .EXAMPLE
        Invoke-PSQualityCheck -File 'C:\Scripts\Script.psd1'
 
        This will call the quality checks with single datafile file
        Note: The datafile test will fail as it is not a file that is accepted for testing
 
        .EXAMPLE
        Invoke-PSQualityCheck -File @('C:\Scripts\Script.ps1','C:\Scripts\Script2.ps1')
 
        This will call the quality checks with multiple files. Files can be either scripts or modules
 
        .EXAMPLE
        Invoke-PSQualityCheck -File 'C:\Scripts\Script.ps1' -ScriptAnalyzerRulesPath 'C:\ScriptAnalyzerRulesPath'
 
        This will call the quality checks with single file and the extra PSScriptAnalyzer rules
 
        .EXAMPLE
        Invoke-PSQualityCheck -Path 'C:\Scripts' -ShowCheckResults
 
        This will display a summary of the checks performed (example below uses sample data):
 
            Name Files Tested Total Passed Failed Skipped
            ---- ------------ ----- ------ ------ -------
            Module Tests 2 14 14 0 0
            Extracting functions 2 2 2 0 0
            Extracted function script tests 22 330 309 0 21
            Total 24 346 325 0 21
 
        For those who have spotted that the Total files tested isn't a total of the rows above, this is because the Module Tests and Extracting function Tests operate on the same file and are then not counted twice
 
        .LINK
        Website: https://github.com/andrewrdavidson/PSQualityCheck
        SonarQube rules are available here: https://github.com/indented-automation/ScriptAnalyzerRules
 
    #>

    [CmdletBinding()]
    [OutputType([System.Void], [HashTable], [System.Object[]])]
    param (
        [Parameter(Mandatory = $true, ParameterSetName = "Path")]
        [String[]]$Path,
        [Parameter(Mandatory = $true, ParameterSetName = "File")]
        [String[]]$File,

        [Parameter(Mandatory = $false, ParameterSetName = "Path")]
        [switch]$Recurse,

        [Parameter(Mandatory = $false)]
        [String[]]$ScriptAnalyzerRulesPath,

        [switch]$ShowCheckResults,

        [switch]$ExportCheckResults,

        [switch]$Passthru,

        [Parameter(Mandatory = $false)]
        [System.Object]$PesterConfiguration,

        [Parameter(Mandatory = $false)]
        [String[]]$Include,

        [Parameter(Mandatory = $false)]
        [String[]]$Exclude

    )

    Set-StrictMode -Version Latest

    # External Modules
    Import-Module -Name 'Pester' -MinimumVersion '5.1.0' -Force
    Import-Module -Name 'PSScriptAnalyzer' -MinimumVersion '1.19.1' -Force

    $modulePath = (Get-Module -Name 'PSQualityCheck').ModuleBase

    # Analyse the incoming Path and File parameters and produce a list of Modules and Scripts

    $scriptsToTest = @()
    $modulesToTest = @()

    Write-Verbose "Starting" -Verbose

    if ($PSBoundParameters.ContainsKey('Path')) {

        Write-Verbose 'Found Path in $PSBoundParameters' -Verbose

        if ($Path -isnot [string[]]) {
            $Path = @($Path)
        }

        foreach ($item in $Path) {

            # Test whether the item is a directory (also tells us if it exists)
            if (Test-Path -Path $item -PathType Container) {

                $getFileListSplat = @{
                    'Path' = $item
                }
                if ($PSBoundParameters.ContainsKey('Recurse')) {
                    $getFileListSplat.Add('Recurse', $true)
                }

                $scriptsToTest += Get-FileList @getFileListSplat -Extension '.ps1'
                $modulesToTest += Get-FileList @getFileListSplat -Extension '.psm1'

            }
            else {

                Write-Warning -Message "$item is not a directory, skipping"

            }

        }

    }

    if ($PSBoundParameters.ContainsKey('File')) {

        Write-Verbose 'Found File in $PSBoundParameters' -Verbose

        if ($File -isnot [string[]]) {
            $File = @($File)
        }

        foreach ($item in $File) {

            # Test whether the item is a file (also tells us if it exists)
            if (Test-Path -Path $item -PathType Leaf) {

                $itemProperties = Get-ChildItem -Path $item

                switch ($itemProperties.Extension) {

                    '.psm1' {
                        $modulesToTest += $itemProperties
                    }

                    '.ps1' {
                        $scriptsToTest += $itemProperties
                    }

                }

            }
            else {

                Write-Warning -Message "$item is not a file, skipping"

            }

        }

    }

    if ($PSBoundParameters.ContainsKey('PesterConfiguration') -and $PesterConfiguration -is [PesterConfiguration]) {

        # left here so that we can over-ride passed in object with values we require

    }
    else {
        # Default Pester Parameters
        $PesterConfiguration = [PesterConfiguration]::Default
        $PesterConfiguration.Run.Exit = $false
        $PesterConfiguration.CodeCoverage.Enabled = $false
        $PesterConfiguration.Output.Verbosity = 'Detailed'
        $PesterConfiguration.Run.PassThru = $true
        $PesterConfiguration.Should.ErrorAction = 'Stop'
    }

    # Get the list of test tags from the checks files
    if ($PSBoundParameters.ContainsKey('Include') -or
        $PSBoundParameters.ContainsKey('Exclude')) {

        Write-Verbose 'Getting Tags' -Verbose

        ($moduleTags, $scriptTags) = Get-TagList
        $moduleTagsToInclude = @()
        $moduleTagsToExclude = @()
        $scriptTagsToInclude = @()
        $scriptTagsToExclude = @()
        $runModuleCheck = $false
        $runScriptCheck = $false

        Write-Verbose "Got ModuleTags: $ModuleTags" -Verbose
        Write-Verbose "Got scriptTags: $scriptTags" -Verbose

    }
    else {
        $runModuleCheck = $true
        $runScriptCheck = $true
    }

    if ($PSBoundParameters.ContainsKey('Include')) {

        Write-Verbose 'Processing -Include' -Verbose
        if ($Include -eq 'All') {
            $moduleTagsToInclude = $moduleTags
            $scriptTagsToInclude = $scriptTags
            $runModuleCheck = $true
            $runScriptCheck = $true
        }
        else {
            # Validate tests to include from $Include
            $Include | ForEach-Object {
                if ($_ -in $moduleTags) {
                    $moduleTagsToInclude += $_
                    $runModuleCheck = $true
                    #* To satisfy PSScriptAnalyzer
                    $runModuleCheck = $runModuleCheck
                    $runScriptCheck = $runScriptCheck
                }
            }
            $Include | ForEach-Object {
                if ($_ -in $scriptTags) {
                    $scriptTagsToInclude += $_
                    $runScriptCheck = $true
                    #* To satisfy PSScriptAnalyzer
                    $runModuleCheck = $runModuleCheck
                    $runScriptCheck = $runScriptCheck
                }
            }
        }
        $PesterConfiguration.Filter.Tag = $moduleTagsToInclude + $scriptTagsToInclude

    }

    if ($PSBoundParameters.ContainsKey('Exclude')) {

        Write-Verbose 'Processing -Exclude' -Verbose
        # Validate tests to exclude from $Exclude
        $Exclude | ForEach-Object {
            if ($_ -in $moduleTags) {
                $moduleTagsToExclude += $_
                $runModuleCheck = $true
                #* To satisfy PSScriptAnalyzer
                $runModuleCheck = $runModuleCheck
                $runScriptCheck = $runScriptCheck
            }
        }
        $Exclude | ForEach-Object {
            if ($_ -in $scriptTags) {
                $scriptTagsToExclude += $_
                $runScriptCheck = $true
                #* To satisfy PSScriptAnalyzer
                $runModuleCheck = $runModuleCheck
                $runScriptCheck = $runScriptCheck
            }
        }
        $PesterConfiguration.Filter.ExcludeTag = $moduleTagsToExclude + $scriptTagsToExclude

    }

    # Need to work out which of the checks files can be run
    if ($PSBoundParameters.ContainsKey('Include') -or
        $PSBoundParameters.ContainsKey('Exclude')) {

        Write-Verbose "moduleTagsToInclude $moduleTagsToInclude" -Verbose
        Write-Verbose "scriptTagsToInclude $scriptTagsToInclude" -Verbose
        Write-Verbose "moduleTagsToExclude $moduleTagsToExclude" -Verbose
        Write-Verbose "scriptTagsToExclude $scriptTagsToExclude" -Verbose

    }
    Write-Verbose "runModuleCheck $runModuleCheck" -Verbose
    Write-Verbose "runScriptCheck $runScriptCheck" -Verbose

    $moduleResults = $null
    $extractionResults = $null
    $extractedScriptResults = $null
    $scriptResults = $null

    if ($modulesToTest.Count -ge 1) {

        # Location of files extracted from any passed modules
        $extractPath = Join-Path -Path ([IO.Path]::GetTempPath()) -ChildPath (New-Guid).Guid

        if ($runModuleCheck -eq $true) {

            # Run the Module tests on all the valid module files found
            $container1 = New-PesterContainer -Path (Join-Path -Path $modulePath -ChildPath 'Checks\Module.Tests.ps1') -Data @{ Source = $modulesToTest }
            $PesterConfiguration.Run.Container = $container1
            $moduleResults = Invoke-Pester -Configuration $PesterConfiguration

            # Extract all the functions from the modules into individual .ps1 files ready for testing
            $container2 = New-PesterContainer -Path (Join-Path -Path $modulePath -ChildPath 'Checks\Function-Extraction.Tests.ps1') -Data @{ Source = $modulesToTest; ExtractPath = $extractPath }
            $PesterConfiguration.Run.Container = $container2
            $extractionResults = Invoke-Pester -Configuration $PesterConfiguration

        }

        if ($runScriptCheck -eq $true -and (Test-Path -Path $extractPath -ErrorAction SilentlyContinue)) {

            # Get a list of the 'extracted' function scripts .ps1 files
            $extractedScriptsToTest = Get-ChildItem -Path $extractPath -Include '*.ps1' -Recurse

            # Run the Script tests against all the extracted functions .ps1 files
            $container3 = New-PesterContainer -Path (Join-Path -Path $modulePath -ChildPath 'Checks\Script.Tests.ps1') -Data @{ Source = $extractedScriptsToTest; ScriptAnalyzerRulesPath = $ScriptAnalyzerRulesPath }
            $PesterConfiguration.Run.Container = $container3
            $extractedScriptResults = Invoke-Pester -Configuration $PesterConfiguration
        }

    }

    if ($scriptsToTest.Count -ge 1 -and $runScriptCheck -eq $true) {

        # Run the Script tests against all the valid script files found
        $container3 = New-PesterContainer -Path (Join-Path -Path $modulePath -ChildPath 'Checks\Script.Tests.ps1') -Data @{ Source = $scriptsToTest; ScriptAnalyzerRulesPath = $ScriptAnalyzerRulesPath }
        $PesterConfiguration.Run.Container = $container3
        $scriptResults = Invoke-Pester -Configuration $PesterConfiguration

    }

    if ($PSBoundParameters.ContainsKey('ShowCheckResults')) {

        $qualityCheckResults = @()
        $filesTested = $total = $passed = $failed = $skipped = 0

        if ($null -ne $moduleResults) {
            $qualityCheckResults +=
            @{
                'Test' = 'Module Tests'
                'Files Tested' = $ModulesToTest.Count
                'Total' = $moduleResults.TotalCount
                'Passed' = $moduleResults.PassedCount
                'Failed' = $moduleResults.FailedCount
                'Skipped' = $moduleResults.SkippedCount
            }
            $filesTested += $ModulesToTest.Count
            $total += $moduleResults.TotalCount
            $passed += $moduleResults.PassedCount
            $failed += $moduleResults.FailedCount
            $skipped += $moduleResults.SkippedCount
        }

        if ($null -ne $extractionResults) {
            $qualityCheckResults +=
            @{
                'Test' = 'Extracting functions'
                'Files Tested' = $ModulesToTest.Count
                'Total' = $extractionResults.TotalCount
                'Passed' = $extractionResults.PassedCount
                'Failed' = $extractionResults.FailedCount
                'Skipped' = $extractionResults.SkippedCount
            }
            $total += $extractionResults.TotalCount
            $passed += $extractionResults.PassedCount
            $failed += $extractionResults.FailedCount
            $skipped += $extractionResults.SkippedCount
        }

        if ($null -ne $extractedScriptResults) {
            $qualityCheckResults +=
            @{
                'Test' = 'Extracted function script tests'
                'Files Tested' = $extractedScriptsToTest.Count
                'Total' = $extractedScriptResults.TotalCount
                'Passed' = $extractedScriptResults.PassedCount
                'Failed' = $extractedScriptResults.FailedCount
                'Skipped' = $extractedScriptResults.SkippedCount
            }
            $filesTested += $extractedScriptsToTest.Count
            $total += $extractedScriptResults.TotalCount
            $passed += $extractedScriptResults.PassedCount
            $failed += $extractedScriptResults.FailedCount
            $skipped += $extractedScriptResults.SkippedCount
        }

        if ($null -ne $scriptResults) {
            $qualityCheckResults +=
            @{
                'Test' = "Script Tests"
                'Files Tested' = $scriptsToTest.Count
                'Total' = $scriptResults.TotalCount
                'Passed' = $scriptResults.PassedCount
                'Failed' = $scriptResults.FailedCount
                'Skipped' = $scriptResults.SkippedCount
            }
            $filesTested += $scriptsToTest.Count
            $total += $scriptResults.TotalCount
            $passed += $scriptResults.PassedCount
            $failed += $scriptResults.FailedCount
            $skipped += $scriptResults.SkippedCount
        }

        $qualityCheckResults +=
        @{
            'Test' = "Total"
            'Files Tested' = $filesTested
            'Total' = $total
            'Passed' = $passed
            'Failed' = $failed
            'Skipped' = $skipped
        }

        # This works on PS5 and PS7
        $qualityCheckResults | ForEach-Object {
            [PSCustomObject]@{
                'Test' = $_.Test
                'Files Tested' = $_.'Files Tested'
                'Total' = $_.total
                'Passed' = $_.passed
                'Failed' = $_.failed
                'Skipped' = $_.skipped
            }
        } | Format-Table -AutoSize

        # This works on PS7 not on PS5
        # $qualityCheckResults | Select-Object Name, 'Files Tested', Total, Passed, Failed, Skipped | Format-Table -AutoSize

    }

    if ($PSBoundParameters.ContainsKey('ExportCheckResults')) {

        $moduleResults | Export-Clixml -Path "moduleResults.xml"
        $extractionResults | Export-Clixml -Path "extractionResults.xml"
        $scriptsToTest | Export-Clixml -Path "scriptsToTest.xml"
        $extractedScriptResults | Export-Clixml -Path "extractedScriptResults.xml"

    }

    if ($PSBoundParameters.ContainsKey('Passthru')) {

        if ($PesterConfiguration.Run.PassThru.Value -eq $true) {
            return $moduleResults, $extractionResults, $scriptsToTest, $extractedScriptResults
        }
        else {
            Write-Error "Unable to pass back result objects. Passthru not enabled in Pester Configuration object"
        }

    }

}