PSModuleUtils.psm1

#Region '.\Private\Get-PSModuleAnalyzerSettingsPath.ps1' -1

<#
.SYNOPSIS
Internal: resolves the bundled analyzer settings across source and built module layouts.
 
.DESCRIPTION
ModuleBuilder merges every source function into a single flat .psm1 while copying the Settings directory
intact. The settings directory is one level above a source function and a direct sibling of the built
.psm1. This probes both locations relative to the caller's own $PSScriptRoot.
 
.PARAMETER CallerScriptRoot
The $PSScriptRoot of the calling function.
 
.OUTPUTS
System.String path to the settings file, or nothing if neither candidate exists.
 
.EXAMPLE
Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $PSScriptRoot
#>

function Get-PSModuleAnalyzerSettingsPath {
    [CmdletBinding()]
    [OutputType([String])]
    param (
        [Parameter(Mandatory)]
        [String]$CallerScriptRoot
    )

    $candidates = @(
        (Join-Path -Path $CallerScriptRoot -ChildPath 'Settings/PSScriptAnalyzerSettings.psd1')
        (Join-Path -Path $CallerScriptRoot -ChildPath '../Settings/PSScriptAnalyzerSettings.psd1')
    )
    $candidates | Where-Object -FilterScript { Test-Path -Path $_ } | Select-Object -First 1
}
#EndRegion '.\Private\Get-PSModuleAnalyzerSettingsPath.ps1' 33
#Region '.\Private\Get-PSModuleGitMetadata.ps1' -1

<#
.SYNOPSIS
Internal: derives module manifest metadata from a git working tree.
 
.DESCRIPTION
Reads the git remote and history for the given path and returns the manifest metadata that should be
stamped onto a built module: author list, company name, copyright, project URI, and release notes.
Returns nothing (with a warning) when the path is not inside a git working tree, so callers can treat
git-derived metadata as best effort.
 
.PARAMETER Path
A path inside the git working tree to read metadata from. Defaults to the current location.
 
.OUTPUTS
PSCustomObject with Author, CompanyName, Copyright, ProjectUri, and ReleaseNotes properties.
 
.EXAMPLE
$metadata = Get-PSModuleGitMetadata -Path $SourceDirectory
#>

function Get-PSModuleGitMetadata {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [String]$Path = "$PWD"
    )

    $ErrorActionPreference = 'Stop'

    Push-Location -Path $Path
    try {
        $insideWorkTree = ( & git rev-parse --is-inside-work-tree 2>$null )
        if ($LASTEXITCODE -ne 0 -or $insideWorkTree -ne 'true') {
            Write-Warning -Message "'$Path' is not inside a git working tree; skipping git-derived metadata."
            return
        }

        $repoUrl = ( & git config --get remote.origin.url )
        if ($LASTEXITCODE -ne 0 -or [String]::IsNullOrWhiteSpace($repoUrl)) {
            Write-Warning -Message "'$Path' has no origin remote; skipping git-derived metadata."
            return
        }
        try {
            $remoteMetadata = Resolve-PSModuleGitRemote -RepositoryUrl $repoUrl
        }
        catch {
            Write-Warning -Message (
                'A company name was not provided and the Git remote URL could not be resolved; ' +
                'leaving CompanyName and ProjectUri blank.'
            )
            $remoteMetadata = [PSCustomObject]@{
                Organization = ''
                ProjectUri   = ''
            }
        }
        $companyName = $remoteMetadata.Organization
        $copyright = if ($companyName) {
            "(c) $( Get-Date -Format yyyy ) $companyName. All rights reserved."
        }
        else {
            ''
        }

        [PSCustomObject]@{
            Author       = (( & git log --format='%aN' -- . | Sort-Object -Unique ) -join ', ')
            CompanyName  = $companyName
            Copyright    = $copyright
            ProjectUri   = $remoteMetadata.ProjectUri
            ReleaseNotes = ( & git log -1 --pretty=%B )[0]
        }
    }
    finally {
        Pop-Location
    }
}
#EndRegion '.\Private\Get-PSModuleGitMetadata.ps1' 75
#Region '.\Private\Get-PSModulePublishedManifest.ps1' -1

<#
.SYNOPSIS
Internal: retrieves a previously published module's manifest from a repository.
 
.DESCRIPTION
Saves a module from the given repository into a temporary directory and returns its manifest as a
hashtable, so callers can reuse identity fields (most importantly GUID) when generating or migrating
a source manifest template for a module that has already been published. Returns nothing (with a
verbose message, not a warning) when no published version is found, since this is an expected
outcome for a module that has never been published.
 
.PARAMETER Name
The name of the module to look up.
 
.PARAMETER Repository
The repository to search. Defaults to PSGallery.
 
.OUTPUTS
System.Collections.Hashtable of the published manifest, or nothing if none was found.
 
.EXAMPLE
Get-PSModulePublishedManifest -Name 'MyModule' -Repository 'PSGallery'
#>

function Get-PSModulePublishedManifest {
    [CmdletBinding()]
    [OutputType([Hashtable])]
    param (
        [Parameter(Mandatory)]
        [String]$Name,

        [String]$Repository = 'PSGallery'
    )

    $lookupPath = Join-Path `
        -Path ([IO.Path]::GetTempPath()) `
        -ChildPath "psmodule-manifest-lookup-$( (New-Guid).Guid )"
    try {
        $null = New-Item -ItemType Directory -Path $lookupPath -Force
        Save-PSResource `
            -Name $Name `
            -Repository $Repository `
            -Path $lookupPath `
            -TrustRepository `
            -ErrorAction SilentlyContinue
        $publishedManifest = Get-ChildItem -Path $lookupPath -Filter "$Name.psd1" -Recurse | Select-Object -First 1
        if ($publishedManifest) {
            Import-PowerShellDataFile -Path $publishedManifest.FullName
        }
        else {
            Write-Verbose -Message "No published version of '$Name' was found in repository '$Repository'."
        }
    }
    finally {
        Remove-Item -Path $lookupPath -Recurse -Force -ErrorAction SilentlyContinue
    }
}
#EndRegion '.\Private\Get-PSModulePublishedManifest.ps1' 57
#Region '.\Private\Invoke-PSModuleAnalyzerCasingWorkaround.ps1' -1

<#
.SYNOPSIS
Internal: invokes PSScriptAnalyzer with a temporary command-casing workaround.
 
.DESCRIPTION
Runs PSUseCorrectCasing sequentially per file when command casing is enabled, then runs the remaining analysis
recursively. PSScriptAnalyzer 1.25.0 can fail while resolving command metadata during a recursive multi-file
command-casing analysis.
 
.PARAMETER Path
The file or directory to analyze.
 
.PARAMETER Settings
A settings file path or a settings hashtable, as Invoke-ScriptAnalyzer itself accepts. A hashtable is
not modified; the split run gets a copy with command casing disabled.
 
.PARAMETER Recurse
Analyzes files recursively.
 
.PARAMETER Severity
The diagnostic severities to return.
 
.PARAMETER EnableExit
Exits with the number of diagnostics found.
 
.PARAMETER ReportSummary
Writes a diagnostic summary.
 
.PARAMETER Fix
Applies supported corrections.
 
.OUTPUTS
Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord
#>

function Invoke-PSModuleAnalyzerCasingWorkaround {
    [CmdletBinding()]
    [OutputType('Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord')]
    param (
        [Parameter(Mandatory)]
        [String]$Path,

        [Parameter(Mandatory)]
        [Object]$Settings,

        [Switch]$Recurse,

        [String[]]$Severity,

        [Switch]$EnableExit,

        [Switch]$ReportSummary,

        [Switch]$Fix
    )

    $recursiveAnalyzerArguments = @{}
    foreach ($argument in $PSBoundParameters.GetEnumerator()) {
        $recursiveAnalyzerArguments[$argument.Key] = $argument.Value
    }

    $settingsData = if ($Settings -is [Collections.IDictionary]) {
        $Settings
    }
    else {
        Import-PowerShellDataFile -Path $Settings -ErrorAction Stop
    }
    $correctCasingRule = $settingsData.Rules.PSUseCorrectCasing
    $includeRules = @($settingsData.IncludeRules | Where-Object { $_ })
    $excludeRules = @($settingsData.ExcludeRules | Where-Object { $_ })
    $correctCasingIncluded = (
        $includeRules.Count -eq 0 -or
        @($includeRules | Where-Object { 'PSUseCorrectCasing' -like $_ }).Count -gt 0
    )
    $correctCasingExcluded = @(
        $excludeRules | Where-Object { 'PSUseCorrectCasing' -like $_ }
    ).Count -gt 0
    $splitCommandCasing = (
        $correctCasingRule.Enable -eq $true -and
        $correctCasingRule.CheckCommands -ne $false -and
        $correctCasingIncluded -and
        -not $correctCasingExcluded
    )

    if ($splitCommandCasing) {
        # Copied rather than edited in place, because a caller that passed a hashtable still owns it.
        $recursiveCasingRule = @{} + $correctCasingRule
        $recursiveCasingRule.Enable = $false
        $recursiveRules = @{} + $settingsData.Rules
        $recursiveRules.PSUseCorrectCasing = $recursiveCasingRule
        $recursiveSettings = @{} + $settingsData
        $recursiveSettings.Rules = $recursiveRules
        $recursiveAnalyzerArguments.Settings = $recursiveSettings
        $casingSettings = @{
            IncludeRules = @('PSUseCorrectCasing')
            Rules        = @{
                PSUseCorrectCasing = @{
                    Enable        = $true
                    CheckCommands = $true
                    CheckKeyword  = ($correctCasingRule.CheckKeyword -ne $false)
                    CheckOperator = ($correctCasingRule.CheckOperator -ne $false)
                }
            }
        }
        $casingArguments = @{
            Settings      = $casingSettings
            Recurse       = $false
            Severity      = $Severity
            EnableExit    = $false
            ReportSummary = $false
            ErrorAction   = 'Stop'
        }
        if ($Fix) {
            $casingArguments.Fix = $true
        }

        $casingResultCount = 0
        Get-ChildItem -Path $Path -Recurse:$Recurse -File -ErrorAction Stop |
            Where-Object { $_.Extension -in '.ps1', '.psm1', '.psd1' } |
            ForEach-Object {
                $casingArguments.Path = $_.FullName
                Invoke-ScriptAnalyzer @casingArguments |
                    ForEach-Object {
                        $casingResultCount++
                        $_
                    }
                }

        Invoke-ScriptAnalyzer @recursiveAnalyzerArguments

        if ($EnableExit -and -not $Fix -and $casingResultCount -gt 0) {
            exit [Math]::Min($casingResultCount, 255)
        }
    }
    else {
        Invoke-ScriptAnalyzer @recursiveAnalyzerArguments
    }
}
#EndRegion '.\Private\Invoke-PSModuleAnalyzerCasingWorkaround.ps1' 138
#Region '.\Private\Resolve-PSModuleGitRemote.ps1' -1

<#
.SYNOPSIS
Internal: resolves a Git remote into module project metadata.
 
.DESCRIPTION
Normalizes HTTPS, SSH, SCP-style SSH, and Git-protocol remote URLs into a browser-friendly project
URI and derives the repository organization or workspace. Includes explicit Azure DevOps handling
and works generically for GitHub, Bitbucket, GitLab, and self-hosted Git services.
 
.PARAMETER RepositoryUrl
Git remote URL to normalize.
 
.OUTPUTS
PSCustomObject with ProjectUri and Organization properties.
 
.EXAMPLE
Resolve-PSModuleGitRemote -RepositoryUrl 'git@github.com:owner/module.git'
#>

function Resolve-PSModuleGitRemote {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory)]
        [ValidateNotNullOrEmpty()]
        [String]$RepositoryUrl
    )

    $remoteUrl = $RepositoryUrl.Trim()
    $parsedUri = $null
    $scheme = $null
    $hostName = $null
    $authority = $null
    $repositoryPath = $null

    if (
        [Uri]::TryCreate($remoteUrl, [UriKind]::Absolute, [ref]$parsedUri) -and
        $parsedUri.Scheme -in @('http', 'https', 'ssh', 'git')
    ) {
        $scheme = $parsedUri.Scheme.ToLowerInvariant()
        $hostName = $parsedUri.Host.ToLowerInvariant()
        $authority = if ($scheme -in @('http', 'https') -and -not $parsedUri.IsDefaultPort) {
            $parsedUri.Authority
        }
        else {
            $hostName
        }
        $repositoryPath = $parsedUri.AbsolutePath.Trim('/')
    }
    elseif ($remoteUrl -match '^(?:[^@/:]+@)?(?<host>[^:/]+):(?<path>.+)$') {
        $matchedHost = $Matches['host']
        $matchedPath = $Matches['path']
        if (-not ($remoteUrl.Contains('@') -or $matchedHost.Contains('.'))) {
            throw "Unsupported Git remote URL format: $RepositoryUrl"
        }

        $scheme = 'ssh'
        $hostName = $matchedHost.ToLowerInvariant()
        $authority = $hostName
        $repositoryPath = $matchedPath.Trim('/')
    }
    else {
        throw "Unsupported Git remote URL format: $RepositoryUrl"
    }

    $repositoryPath = $repositoryPath -replace '(?i)\.git$', ''
    $pathSegments = @(
        $repositoryPath.Split(
            '/',
            [StringSplitOptions]::RemoveEmptyEntries
        )
    )
    if ($pathSegments.Count -eq 0) {
        throw "Git remote URL has no repository path: $RepositoryUrl"
    }

    $organization = $pathSegments[0]
    $projectUri = $null
    if (
        $hostName -in @('ssh.dev.azure.com', 'vs-ssh.visualstudio.com') -and
        $pathSegments.Count -ge 4 -and
        $pathSegments[0] -eq 'v3'
    ) {
        $organization = $pathSegments[1]
        $project = $pathSegments[2]
        $repository = ($pathSegments[3..($pathSegments.Count - 1)] -join '/')
        $projectUri = "https://dev.azure.com/$organization/$project/_git/$repository"
    }
    elseif ($hostName -eq 'dev.azure.com' -and $pathSegments.Count -ge 2) {
        $organization = $pathSegments[0]
        $projectUri = "https://dev.azure.com/$repositoryPath"
    }
    elseif ($hostName -like '*.visualstudio.com') {
        $organization = $hostName.Split('.')[0]
        $projectUri = "https://$hostName/$repositoryPath"
    }
    else {
        $webScheme = if ($scheme -in @('http', 'https')) {
            $scheme
        }
        else {
            'https'
        }
        $projectUri = "$($webScheme)://$authority/$repositoryPath"
    }

    [PSCustomObject]@{
        ProjectUri   = $projectUri
        Organization = $organization
    }
}
#EndRegion '.\Private\Resolve-PSModuleGitRemote.ps1' 111
#Region '.\Public\Build-PSModule.ps1' -1

<#
.SYNOPSIS
Builds a PowerShell module from a source tree using ModuleBuilder.
 
.DESCRIPTION
Compiles a module's public and private function files into a single versioned module under the output
directory with ModuleBuilder's Build-Module, then stamps git-derived metadata (author, company,
copyright, project URI, release notes) onto the built manifest with Update-Metadata.
 
Files and directories outside the compiled source folders are copied into the published module automatically.
Additional paths can be copied with CopyPaths. Copy paths retain their source names beneath the module output
directory and are never concatenated into the generated .psm1.
 
The source directory must contain a hand-authored manifest template named "<Name>.psd1" and the
function folders it references. ModuleBuilder derives FunctionsToExport from the public filter and
AliasesToExport from [Alias()], New-Alias, and Set-Alias declarations, so those keys are never authored
by hand. Tests must live outside the source folders (ModuleBuilder inlines every .ps1 it finds in them).
 
To allow the version, prerelease, and release notes to be stamped, the manifest template must
pre-declare PrivateData.PSData.Prerelease and PrivateData.PSData.ReleaseNotes (empty strings are fine).
Run New-PSModuleManifest to generate a compatible manifest template for a module that does not have one.
 
.PARAMETER Name
The name of the module. When omitted, the name is derived from the single module manifest in SourceDirectory.
 
.PARAMETER Version
The module version, optionally with a SemVer prerelease label (e.g. "2.0.0-alpha"). When omitted, the
version already declared in the source manifest is used.
 
.PARAMETER SourceDirectory
The directory containing the source manifest and the canonical Public/Private function folders.
 
.PARAMETER OutputDirectory
The directory to build into. The module is written to "<OutputDirectory>/<Name>/<Version>".
 
.PARAMETER SourceDirectories
The source subfolders, in load order, that ModuleBuilder concatenates into the built module.
 
.PARAMETER PublicFilter
The filter identifying public (exported) function files, relative to the source directory.
 
.PARAMETER CopyPaths
Additional files or directories to copy recursively into the built module without compilation. Paths are
relative to the source directory unless absolute. Source-root items outside SourceDirectories are copied
automatically.
 
.PARAMETER SkipGitMetadata
Skips stamping git-derived metadata onto the built manifest. Useful when building outside a git tree.
 
.OUTPUTS
System.IO.FileInfo for the built module manifest.
 
.EXAMPLE
Build-PSModule -SourceDirectory "$PWD/src"
 
.EXAMPLE
Build-PSModule -Name 'MyModule' -Version '2.0.0-preview1' -SourceDirectory "$PWD/src"
 
.NOTES
Requires the ModuleBuilder and Metadata modules.
#>

function Build-PSModule {
    [CmdletBinding()]
    [OutputType([System.IO.FileInfo])]
    param (
        [String]$Name,
        [String]$Version,
        [String]$SourceDirectory = "$PWD/src",
        [String]$OutputDirectory = "$PWD/out",
        [String[]]$SourceDirectories = @('Enum', 'Classes', 'Private', 'Public'),
        [String]$PublicFilter = 'Public/*.ps1',
        [String[]]$CopyPaths = @(),
        [Switch]$SkipGitMetadata
    )

    $ErrorActionPreference = 'Stop'

    if ($Name) {
        $sourceManifest = Join-Path -Path $SourceDirectory -ChildPath "$Name.psd1"
        if (-not (Test-Path -Path $sourceManifest)) {
            throw "Source manifest not found at '$sourceManifest'. Run New-PSModuleManifest to create one."
        }
    }
    else {
        $sourceManifests = @(Get-ChildItem -LiteralPath $SourceDirectory -Filter '*.psd1' -File)
        if ($sourceManifests.Count -ne 1) {
            throw "Expected one module manifest in '$SourceDirectory', but found $($sourceManifests.Count)."
        }
        $sourceManifest = $sourceManifests[0].FullName
        $Name = $sourceManifests[0].BaseName
    }

    $canonicalSourceDirectories = @('Enum', 'Classes', 'Private', 'Public')
    foreach ($requestedDirectory in $SourceDirectories) {
        $canonicalName = $canonicalSourceDirectories |
            Where-Object { $_ -ieq $requestedDirectory } |
            Select-Object -First 1
        if ($canonicalName -and $requestedDirectory -cne $canonicalName) {
            throw "Source directory '$requestedDirectory' must be named '$canonicalName'."
        }
    }

    foreach ($sourceChild in Get-ChildItem -LiteralPath $SourceDirectory -Directory) {
        $canonicalName = $canonicalSourceDirectories |
            Where-Object { $_ -ieq $sourceChild.Name } |
            Select-Object -First 1
        if ($canonicalName -and $sourceChild.Name -cne $canonicalName) {
            throw "Source directory '$($sourceChild.Name)' must be named '$canonicalName'."
        }
    }

    $moduleOutputRoot = Join-Path -Path $OutputDirectory -ChildPath $Name
    Remove-Item -Path $moduleOutputRoot -Recurse -Force -ErrorAction SilentlyContinue

    $buildModule = @{
        SourcePath               = $sourceManifest
        OutputDirectory          = $OutputDirectory
        SourceDirectories        = $SourceDirectories
        PublicFilter             = $PublicFilter
        VersionedOutputDirectory = $true
        Passthru                 = $true
    }

    $compiledSourceRoots = foreach ($sourceDirectoryName in $SourceDirectories) {
        $normalizedSourceDirectory = $sourceDirectoryName -replace '\\', '/'
        ($normalizedSourceDirectory -split '/', 2)[0]
    }
    $excludedSourceItems = @(
        (Split-Path -Path $sourceManifest -Leaf)
        "$Name.psm1"
    )
    $automaticCopyPaths = foreach ($sourceItem in Get-ChildItem -LiteralPath $SourceDirectory -Force) {
        $isCompiledSourceDirectory = $sourceItem.PSIsContainer -and @(
            $compiledSourceRoots | Where-Object { $sourceItem.Name -like $_ }
        ).Count -gt 0
        if (-not $isCompiledSourceDirectory -and $sourceItem.Name -notin $excludedSourceItems) {
            $sourceItem.Name
        }
    }
    $resolvedCopyPaths = @(
        $automaticCopyPaths
        $CopyPaths
    ) | Select-Object -Unique
    if ($resolvedCopyPaths.Count -gt 0) {
        $buildModule['CopyPaths'] = $resolvedCopyPaths
    }

    if ($Version) {
        $moduleVersion, $modulePrerelease = $Version -split '-', 2
        $buildModule['Version'] = $moduleVersion
        if ($modulePrerelease) {
            $buildModule['Prerelease'] = $modulePrerelease
        }
    }

    Write-Host -Object "Building module '$Name'..."
    $builtModule = Build-Module @buildModule
    $manifestPath = Join-Path -Path $builtModule.ModuleBase -ChildPath "$Name.psd1"

    if (-not $SkipGitMetadata) {
        $gitMetadata = Get-PSModuleGitMetadata -Path $SourceDirectory
        if ($gitMetadata) {
            $metadataMap = [ordered]@{
                'Author'                          = $gitMetadata.Author
                'CompanyName'                     = $gitMetadata.CompanyName
                'Copyright'                       = $gitMetadata.Copyright
                'PrivateData.PSData.ProjectUri'   = $gitMetadata.ProjectUri
                'PrivateData.PSData.ReleaseNotes' = $gitMetadata.ReleaseNotes
            }
            foreach ($property in $metadataMap.Keys) {
                $value = $metadataMap[$property]
                if (-not $value) {
                    continue
                }
                try {
                    Update-Metadata -Path $manifestPath -PropertyName $property -Value $value -ErrorAction Stop
                }
                catch {
                    $updateError = $_
                    Write-Warning -Message (
                        "Could not set '$property' in '$manifestPath': $( $updateError.Exception.Message )"
                    )
                }
            }
        }
    }

    Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue
    Import-Module -Name $manifestPath -Force -Global -ErrorAction Stop
    Get-Item -Path $manifestPath
}
#EndRegion '.\Public\Build-PSModule.ps1' 192
#Region '.\Public\Export-PSModuleAnalyzerSettings.ps1' -1

<#
.SYNOPSIS
Exports the bundled PSScriptAnalyzer settings for customization.
 
.DESCRIPTION
Copies the PSScriptAnalyzer settings bundled with PSModuleUtils to a user-selected path. The file is copied
verbatim so comments, disabled examples, ordering, and formatting remain available for customization.
 
.PARAMETER Path
The destination file path. Defaults to PSScriptAnalyzerSettings.psd1 in the current directory. The parent
directory must already exist.
 
.PARAMETER Force
Replaces an existing destination file.
 
.PARAMETER PassThru
Returns the exported file.
 
.OUTPUTS
System.IO.FileInfo when PassThru is specified. Otherwise, this command produces no output.
 
.EXAMPLE
Export-PSModuleAnalyzerSettings
 
.EXAMPLE
Export-PSModuleAnalyzerSettings -Path ./config/PSScriptAnalyzerSettings.psd1 -Force -PassThru
#>

function Export-PSModuleAnalyzerSettings {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Position = 0)]
        [ValidateNotNullOrEmpty()]
        [String]$Path = (Join-Path -Path $PWD -ChildPath 'PSScriptAnalyzerSettings.psd1'),

        [Switch]$Force,

        [Switch]$PassThru
    )

    $sourcePath = Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $PSScriptRoot
    if ([String]::IsNullOrWhiteSpace($sourcePath) -or -not (Test-Path -LiteralPath $sourcePath -PathType Leaf)) {
        throw 'The bundled PSScriptAnalyzer settings file could not be resolved.'
    }

    try {
        $destinationPath = $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path)
    }
    catch {
        $pathError = $_
        throw "Destination path '$Path' could not be resolved: $($pathError.Exception.Message)"
    }

    $parentPath = Split-Path -Path $destinationPath -Parent
    if (-not (Test-Path -LiteralPath $parentPath -PathType Container)) {
        throw "The destination parent directory '$parentPath' does not exist."
    }

    if (Test-Path -LiteralPath $destinationPath) {
        if (Test-Path -LiteralPath $destinationPath -PathType Container) {
            throw "The destination path '$destinationPath' is a directory."
        }
        if (-not $Force) {
            throw "The destination file '$destinationPath' already exists. Specify -Force to replace it."
        }
    }

    if ($PSCmdlet.ShouldProcess($destinationPath, 'Export bundled PSScriptAnalyzer settings')) {
        $null = Copy-Item -LiteralPath $sourcePath -Destination $destinationPath -Force:$Force -ErrorAction Stop
        if ($PassThru) {
            Get-Item -LiteralPath $destinationPath -ErrorAction Stop
        }
    }
}
#EndRegion '.\Public\Export-PSModuleAnalyzerSettings.ps1' 75
#Region '.\Public\Invoke-PSModuleAnalyzer.ps1' -1

<#
.SYNOPSIS
Invokes PSScriptAnalyzer on a directory using a more strict set of rules than default.
 
.DESCRIPTION
Invokes PSScriptAnalyzer on a directory using a more strict set of rules than default, and works
around its recursive PSUseCorrectCasing crash by applying that one rule a file at a time.
 
Returns diagnostics, like Invoke-ScriptAnalyzer does. Decide what a finding means at the call site:
throw to gate a build, pipe to ConvertTo-SARIF to report one, or pass -EnableExit for a CI step that
should fail on its exit code.
 
.PARAMETER SourceDirectory
The directory to analyze. Also accepts a file path or a wildcard such as scripts/*.ps1, which pairs
with -NoRecurse to analyze a directory's own scripts without descending into folders beneath it.
 
.PARAMETER Settings
A settings file path or a settings hashtable, as Invoke-ScriptAnalyzer itself accepts. Defaults to the
bundled PSScriptAnalyzerSettings.psd1, resolved for both a source checkout and a built module layout.
Pass a hashtable when the settings are assembled at run time, so nothing has to be written to disk.
 
.PARAMETER Fix
Whether to fix the issues found.
 
.PARAMETER NoRecurse
Analyzes only what SourceDirectory itself matches instead of descending into subdirectories. Use with
a wildcard to keep a subtree with different rules, such as a tests folder, out of the run.
 
.PARAMETER EnableExit
Passed through to Invoke-ScriptAnalyzer: asks the host to exit with the diagnostic count once the run
finishes. Suits a CI step that is one analyzer call. It does not stop the caller and the code is
last-writer-wins, so a script analyzing several paths should test the returned diagnostics instead.
 
.OUTPUTS
Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord
 
.EXAMPLE
Invoke-PSModuleAnalyzer -SourceDirectory $PWD/src -Fix
 
.EXAMPLE
Invoke-PSModuleAnalyzer -SourceDirectory $PWD/scripts/*.ps1 -NoRecurse
 
.EXAMPLE
$findings = Invoke-PSModuleAnalyzer -SourceDirectory $PWD/tests
if ($findings) {
    throw "$($findings.Count) rule violation(s) in tests."
}
 
.EXAMPLE
Invoke-PSModuleAnalyzer -SourceDirectory $PWD/src -EnableExit
 
.EXAMPLE
$settings = Import-PowerShellDataFile -Path ./PSScriptAnalyzerSettings.psd1
$settings.ExcludeRules += 'PSUseShouldProcessForStateChangingFunctions'
Invoke-PSModuleAnalyzer -SourceDirectory $PWD/tests -Settings $settings
 
.NOTES
N/A
#>

function Invoke-PSModuleAnalyzer {
    [CmdletBinding()]
    [OutputType('Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord')]
    param (
        [String]$SourceDirectory = "$PWD/src",
        [Object]$Settings = (Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $PSScriptRoot),
        [Switch]$Fix,
        [Switch]$NoRecurse,
        [Switch]$EnableExit
    )

    $scriptAnalyzerArgs = @{
        Path          = $SourceDirectory
        Settings      = $Settings
        Recurse       = (-not $NoRecurse)
        Severity      = 'Error', 'Warning', 'Information'
        EnableExit    = $EnableExit
        ReportSummary = $true
        ErrorAction   = 'Stop'
    }

    if ($Fix) {
        $scriptAnalyzerArgs.Fix = $true
    }

    # After PSScriptAnalyzer fixes recursive PSUseCorrectCasing command metadata resolution, uncomment this call
    # and remove the private workaround and its tests.
    # Invoke-ScriptAnalyzer @scriptAnalyzerArgs
    Invoke-PSModuleAnalyzerCasingWorkaround @scriptAnalyzerArgs
}
#EndRegion '.\Public\Invoke-PSModuleAnalyzer.ps1' 90
#Region '.\Public\New-PSModuleManifest.ps1' -1

<#
.SYNOPSIS
Generates or migrates a hand-authored source module manifest template for use with Build-PSModule.
 
.DESCRIPTION
Writes a source manifest template ("<Name>.psd1") in the shape Build-PSModule expects: pinned
identity fields (GUID, Description, RequiredModules, Tags, LicenseUri), empty export lists (populated
by ModuleBuilder at build time), and pre-declared PrivateData.PSData.Prerelease/ReleaseNotes keys so
Update-Metadata can patch them on a later build.
 
When the module has already been published, its GUID is reused automatically (unless -Guid or
-SkipRepositoryLookup is given) so the module's identity survives the migration to this build model.
Refuses to overwrite an existing manifest unless -Force is given.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER SourceDirectory
The directory to write "<Name>.psd1" into.
 
.PARAMETER Description
The module description.
 
.PARAMETER ModuleVersion
The initial module version written to the source manifest.
 
.PARAMETER Guid
The module GUID. When omitted, the GUID is reused from a previously published version of the module
(see -Repository), or a new one is generated if none is found.
 
.PARAMETER Tags
The module tags, including PSGallery filtering tags such as "PSEdition_Core".
 
.PARAMETER LicenseUri
The URL for the module's license.
 
.PARAMETER PowerShellVersion
The minimum PowerShell version the module requires.
 
.PARAMETER CompatiblePSEditions
The PowerShell editions the module supports.
 
.PARAMETER RequiredModules
The module's runtime dependencies, in the same form accepted by a manifest's RequiredModules key
(module names or hashtables with ModuleName/ModuleVersion/MaximumVersion).
 
.PARAMETER Repository
The repository to search for a previously published version of the module. Defaults to PSGallery.
 
.PARAMETER SkipRepositoryLookup
Skips looking up a previously published version of the module.
 
.PARAMETER Force
Overwrites an existing source manifest at the target path.
 
.OUTPUTS
System.IO.FileInfo for the written manifest.
 
.EXAMPLE
New-PSModuleManifest -Name 'MyModule' -Description 'A PowerShell module.' -SourceDirectory "$PWD/src"
 
.NOTES
Requires the JBUtils module (ConvertTo-Psd1).
#>

function New-PSModuleManifest {
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([System.IO.FileInfo])]
    param (
        [Parameter(Mandatory)]
        [String]$Name,

        [String]$SourceDirectory = "$PWD/src",

        [String]$Description = 'A PowerShell module.',

        [Version]$ModuleVersion = '0.0.1',

        [String]$Guid,

        [String[]]$Tags = @('PSEdition_Core'),

        [String]$LicenseUri = 'https://opensource.org/licenses/MIT',

        [String]$PowerShellVersion = '7.4',

        [String[]]$CompatiblePSEditions = @('Core'),

        [Object[]]$RequiredModules = @(),

        [String]$Repository = 'PSGallery',

        [Switch]$SkipRepositoryLookup,

        [Switch]$Force
    )

    $ErrorActionPreference = 'Stop'

    $manifestPath = Join-Path -Path $SourceDirectory -ChildPath "$Name.psd1"
    if ((Test-Path -Path $manifestPath) -and -not $Force) {
        throw "A source manifest already exists at '$manifestPath'. Use -Force to overwrite it."
    }

    $resolvedGuid = $Guid
    if (-not $resolvedGuid -and -not $SkipRepositoryLookup) {
        $published = Get-PSModulePublishedManifest -Name $Name -Repository $Repository
        if ($published) {
            $resolvedGuid = $published.Guid
            Write-Verbose -Message "Reusing the GUID from the previously published '$Name' module."
        }
    }
    if (-not $resolvedGuid) {
        $resolvedGuid = ( New-Guid ).Guid
    }

    $manifestContent = [ordered]@{
        RootModule           = "$Name.psm1"
        ModuleVersion        = $ModuleVersion.ToString()
        GUID                 = $resolvedGuid
        Author               = ''
        CompanyName          = ''
        Copyright            = ''
        Description          = $Description
        PowerShellVersion    = $PowerShellVersion
        CompatiblePSEditions = $CompatiblePSEditions
        FunctionsToExport    = @()
        CmdletsToExport      = @()
        VariablesToExport    = @()
        AliasesToExport      = @()
    }
    if ($RequiredModules.Count -gt 0) {
        $manifestContent['RequiredModules'] = $RequiredModules
    }
    $manifestContent['PrivateData'] = @{
        PSData = @{
            Tags         = $Tags
            ProjectUri   = ''
            LicenseUri   = $LicenseUri
            ReleaseNotes = ''
            Prerelease   = ''
        }
    }

    if ($PSCmdlet.ShouldProcess($manifestPath, 'Create source module manifest')) {
        $null = New-Item -ItemType Directory -Path $SourceDirectory -Force
        $serialized = ConvertTo-Psd1 -InputObject $manifestContent
        # ConvertTo-Psd1 joins lines with a bare LF; -NoNewline stops Set-Content from appending the
        # platform's native terminator on top of that, which would otherwise leave the file LF-bodied
        # but CRLF-terminated and trip PSScriptAnalyzer's "mixed line endings" detection.
        Set-Content -Path $manifestPath -Value "$serialized`n" -Encoding utf8NoBOM -NoNewline
        Get-Item -Path $manifestPath
    }
}
#EndRegion '.\Public\New-PSModuleManifest.ps1' 154
#Region '.\Public\Publish-PSModule.ps1' -1

<#
.SYNOPSIS
Publishes a PowerShell module to a repository.
 
.DESCRIPTION
Publishes a PowerShell module to a repository. Defaults to PSGallery.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER OutputDirectory
The build output directory used in Build-PSModule.
 
.PARAMETER Repository
The repository to publish to. Defaults to PSGallery.
 
.PARAMETER ApiKey
The API key to use for publishing. Defaults to $env:PSGALLERYAPIKEY.
 
.EXAMPLE
Publish-PSModule -Name 'MyModule' -OutputDirectory "$PWD/out" -Repository 'PSGallery'
 
.NOTES
N/A
#>

function Publish-PSModule {
    [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
    param (
        [String]$Name = 'PSModule',
        [String]$OutputDirectory = "$PWD/out",
        [String]$Repository = 'PSGallery',
        [String]$ApiKey = $env:PSGALLERYAPIKEY
    )

    Get-Module -Name $Name -All | Remove-Module -Force -Confirm:$false -ErrorAction SilentlyContinue

    $versionedFolder = Get-ChildItem -Path "$OutputDirectory/$Name" | Select-Object -Last 1
    if ($versionedFolder) {
        Import-Module -Name "$($versionedFolder.FullName)/$Name.psd1" -Force -Global
        Publish-PSResource `
            -Path $versionedFolder.FullName `
            -ApiKey $ApiKey `
            -Repository $Repository

        $maxRetries = 5
        $attempt = 0
        $delayIntervals = 1, 2, 3, 5, 8
        do {
            try {
                $publishedModule = Find-PSResource `
                    -Name $Name `
                    -Version $versionedFolder.BaseName `
                    -Prerelease `
                    -Repository $Repository
                break
            }
            catch {
                Write-Warning -Message (
                    "Couldn't find published module. Retrying after $($delayIntervals[$attempt]) seconds."
                )
                Start-Sleep -Seconds $delayIntervals[$attempt]
                $attempt++
                if ($attempt -ge $maxRetries) {
                    throw $_
                }
            }
        }
        while (-not $publishedModule -and $attempt -lt $maxRetries)
    }
    else {
        Write-Warning -Message "No module named $Name found to publish."
    }
}
#EndRegion '.\Public\Publish-PSModule.ps1' 74
#Region '.\Public\Test-PSModule.ps1' -1

<#
.SYNOPSIS
Tests a PowerShell module using Pester.
 
.DESCRIPTION
Tests a PowerShell module using Pester. The function installs Pester, removes any existing module with the same name,
and runs Pester with a configuration optimized for running in a CI pipeline.
 
.PARAMETER Name
The name of the module.
 
.PARAMETER SourceDirectory
The source directory of the module. Used as the code coverage target, not for test discovery.
 
.PARAMETER TestPath
The directory to discover and run "*.Tests.ps1" files from.
 
.PARAMETER ResultsDirectory
The directory to write testResults.xml and coverage.xml to. Defaults to "out/tests" under the
current directory, keeping generated reports with the other build artifacts instead of beside the
test sources.
 
.PARAMETER Exclude
The directories to exclude from testing and code coverage.
 
.PARAMETER Tag
The tag to filter tests by.
 
.EXAMPLE
Test-PSModule -Name 'MyModule' -SourceDirectory "$PWD/src" -TestPath "$PWD/tests" -Tag 'Unit'
 
.NOTES
N/A
#>

function Test-PSModule {
    [CmdletBinding()]
    param (
        [String]$Name = 'PSModule',
        [String]$SourceDirectory = "$PWD/src",
        [String]$TestPath = "$PWD/tests",
        [String]$ResultsDirectory = "$PWD/out/tests",
        [String[]]$Exclude,
        [String[]]$Tag
    )

    $testFiles = Get-ChildItem -Path $TestPath -Filter '*.Tests.ps1' -Recurse -ErrorAction SilentlyContinue
    if (-not $testFiles) {
        Write-Warning -Message "No test files found in $TestPath"
        return
    }
    Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue
    $config = New-PesterConfiguration @{
        Run          = @{
            Path        = $TestPath
            ExcludePath = $Exclude
        }
        CodeCoverage = @{
            Enabled    = $true
            Path       = $SourceDirectory
            OutputPath = Join-Path $ResultsDirectory 'coverage.xml'
        }
        TestResult   = @{
            Enabled    = $true
            OutputPath = Join-Path $ResultsDirectory 'testResults.xml'
        }
        Output       = @{
            Verbosity = 'Detailed'
        }
    }
    if ($Tag) {
        $config.Filter.Tag = $Tag
    }

    $config.Run.Throw = $true

    Write-Verbose -Message 'Running Pester tests with the following configuration:'
    Write-Verbose -Message ( $config | ConvertTo-Json -Depth 5 )
    Invoke-Pester -Configuration $config
}
#EndRegion '.\Public\Test-PSModule.ps1' 80