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 |