diff --git a/.github/workflows/powershell.yml b/.github/workflows/powershell.yml index 1dd86a3..47dba4e 100644 --- a/.github/workflows/powershell.yml +++ b/.github/workflows/powershell.yml @@ -18,4 +18,7 @@ permissions: jobs: analyze: uses: thisjustin816/reusable-workflows/.github/workflows/ps-scriptAnalyzer.yml@main + with: + path: src + use_local_psmoduleutils: true secrets: inherit diff --git a/.gitignore b/.gitignore index aa41268..e5b864a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ out/* -tests/* +tests/coverage.xml +tests/testResults.xml diff --git a/README.md b/README.md index 0bcdb5e..22b433b 100644 --- a/README.md +++ b/README.md @@ -17,13 +17,15 @@ Follow [The PowerShell Best Practices and Style Guide](https://poshcode.gitbooks Use the following additional guidelines: -- The module file itself (`.psm1`) should not contain any functions or logic, in most cases, other than a `foreach` loop to dot source all the `.ps1` files and `New-Alias` statements for specific functions. +- Modules are built with [ModuleBuilder](https://github.com/PoshCode/ModuleBuilder) via `Build-PSModule`. There is no source `.psm1`: ModuleBuilder concatenates every `.ps1` under `Public`/`Private` into a single built `.psm1`, so a module's source directory holds only a manifest template (`ModuleName.psd1`) and its function folders. +- The manifest template is hand-authored and treated as a source file, not a generated one. `Build-PSModule` copies it forward and only overwrites `FunctionsToExport`, `AliasesToExport`, the version/prerelease, and git-derived fields (`Author`, `CompanyName`, `Copyright`, `ProjectUri`, `ReleaseNotes`) — everything else you author (`GUID`, `Description`, `RequiredModules`, `PrivateData.PSData.Tags`, etc.) is preserved as-is. To allow the version/prerelease/release notes to be stamped, pre-declare `PrivateData.PSData.Prerelease` and `PrivateData.PSData.ReleaseNotes` in the template (empty strings are fine). Run `New-PSModuleManifest` to scaffold or migrate a compatible template. - Ideally, each module and each of its functions should have a set of [Pester](https://github.com/pester/Pester) unit/integration tests. At the least, any new functions or functionality should have an associated test. - Create all functions as single `.ps1` files with the same name and without `Export-ModuleMember` statements. - The files should be in an appropriate nested `Public` folder that corresponds to its API category. - Functions that are used by other functions should be put in either `Utils` or `Private`, depending on their usage. -- The module file (`.psm1`) and each function should have a corresponding `.Tests.ps1` file containing Pester unit/integration tests. -- Don't change any documentation or manifest files; they are automatically populated by the pipeline. +- **Tests must live in a top-level `tests/` folder, never inside `Public`/`Private`.** ModuleBuilder inlines every `.ps1` it finds in those folders with no exclusion, so a co-located `*.Tests.ps1` leaks `Describe`/`It` blocks into the built module and its exports. +- Keep packaged assets outside `Public` and `Private`, then pass their paths to `Build-PSModule -CopyPaths`. Use names that describe their role, such as `Assemblies`, `bin/`, `Settings`, `Schemas`, `Templates`, `Resources`, or culture names such as `en-US`. ModuleBuilder copies each path intact while compiling only the configured source directories into the generated `.psm1`. +- Declare files that PowerShell loads as part of module import in the manifest. Use `RequiredAssemblies` for prerequisite DLLs, `FormatsToProcess` for formatting files, and `TypesToProcess` for type extensions. Use `FileList` only as package inventory. Other runtime assets can be resolved relative to `$PSScriptRoot`; see `Get-PSModuleAnalyzerSettingsPath` for handling source and built layouts. The folder structure should be maintained like the example below: @@ -34,17 +36,25 @@ The folder structure should be maintained like the example below: ├───.gitignore ├───LICENSE ├───README.md +├───build.ps1 │ -└───src +├───src +│ ├───ModuleName.psd1 +│ │ +│ ├───Public +│ │ └───functionalArea +│ │ └───Verb-Noun.ps1 +│ │ +│ ├───Private +│ │ └───Verb-Noun.ps1 +│ │ +│ ├───Assemblies +│ │ └───Dependency.dll +│ │ +│ └───Resources +│ └───Template.json +│ +└───tests ├───ModuleName.Module.Tests.ps1 - ├───ModuleName.psm1 - │ - public - ├───functionalArea - │ ├───Verb-Noun.ps1 - │ └───Verb-Noun.Tests.ps1 - │ - private - ├───Verb-Noun.ps1 └───Verb-Noun.Tests.ps1 ``` diff --git a/build.ps1 b/build.ps1 index db36c7d..79727ef 100644 --- a/build.ps1 +++ b/build.ps1 @@ -1,13 +1,23 @@ $BuildPSModule = @{ - Name = 'PSModuleUtils' - Version = '1.8.0' - Guid = '3c63c38f-c32c-4837-a6fa-0b456f4099ce' - Description = 'A module with helper functions to build and publish PowerShell modules to the PSGallery.' - Tags = ('PSEdition_Desktop', 'PSEdition_Core', 'Windows') + Name = 'PSModuleUtils' + Version = '2.0.0' + CopyPaths = 'Settings' } Push-Location -Path $PSScriptRoot -Import-Module -Name "$PSScriptRoot/src/$($BuildPSModule['Name']).psm1" -Force +Import-Module -FullyQualifiedName @{ + ModuleName = 'ModuleBuilder' + ModuleVersion = '3.0.0' + MaximumVersion = '3.*' +}, +@{ + ModuleName = 'Metadata' + ModuleVersion = '1.5.0' + MaximumVersion = '1.*' +} -ErrorAction Stop +Import-Module -Name 'Pester' -MinimumVersion '5.0' -MaximumVersion '5.*' -ErrorAction Stop +Get-ChildItem -Path "$PSScriptRoot/src/Private", "$PSScriptRoot/src/Public" -Filter '*.ps1' -Recurse | + ForEach-Object -Process { . $_.FullName } if (-not $env:GITHUB_ACTIONS) { Invoke-PSModuleAnalyzer -Fix } diff --git a/src/PSModuleUtils.Module.Tests.ps1 b/src/PSModuleUtils.Module.Tests.ps1 deleted file mode 100644 index 7831285..0000000 --- a/src/PSModuleUtils.Module.Tests.ps1 +++ /dev/null @@ -1,30 +0,0 @@ -# Pester declares parameters in lowercase (e.g. -name on It). -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] -param () - -Describe 'Module Validation' { - BeforeAll { - $Script:module = Get-Item $PSCommandPath.Replace('.Module.Tests.ps1', '.psm1' ) - } - - Context 'module' { - It 'should not contain functions' { - $Script:module.FullName | Should -Not -FileContentMatch 'function' - } - - It 'should not export any package management functions' { - Remove-Module -Name $Script:module.BaseName -Force -ErrorAction SilentlyContinue - $pkgMgmtFunctions = Get-Command -Module ( - 'PackageManagement', 'PowerShellGet', 'Microsoft.PowerShell.PSResourceGet' - ) - Import-Module -Name $Script:module.FullName -Force - $moduleFunctions = Get-Command -Module $Script:module.BaseName - foreach ($function in $pkgMgmtFunctions) { - foreach ($moduleFunction in $moduleFunctions) { - $moduleFunction.Name | Should -Not -Be $function.Name - } - } - Remove-Module -Name $Script:module.BaseName -Force - } - } -} diff --git a/src/PSModuleUtils.psd1 b/src/PSModuleUtils.psd1 new file mode 100644 index 0000000..fb36888 --- /dev/null +++ b/src/PSModuleUtils.psd1 @@ -0,0 +1,32 @@ +@{ + RootModule = 'PSModuleUtils.psm1' + ModuleVersion = '2.0.0' + GUID = '3c63c38f-c32c-4837-a6fa-0b456f4099ce' + Author = '' + CompanyName = '' + Copyright = '' + Description = 'A module with helper functions to build and publish PowerShell modules to the PSGallery.' + PowerShellVersion = '7.4' + CompatiblePSEditions = @('Core') + FunctionsToExport = @() + CmdletsToExport = @() + VariablesToExport = @() + AliasesToExport = @() + RequiredModules = @( + @{ ModuleName = 'ModuleBuilder'; ModuleVersion = '3.0.0'; MaximumVersion = '3.*' } + @{ ModuleName = 'Metadata'; ModuleVersion = '1.5.0'; MaximumVersion = '1.*' } + @{ ModuleName = 'JBUtils'; ModuleVersion = '1.1.0'; MaximumVersion = '1.*' } + @{ ModuleName = 'Pester'; ModuleVersion = '5.0'; MaximumVersion = '5.*' } + @{ ModuleName = 'PSScriptAnalyzer'; ModuleVersion = '1.20.0'; MaximumVersion = '1.*' } + @{ ModuleName = 'Microsoft.PowerShell.PSResourceGet'; ModuleVersion = '1.0.0' } + ) + PrivateData = @{ + PSData = @{ + Tags = @('PSEdition_Core', 'Windows', 'Linux', 'macOS') + ProjectUri = '' + LicenseUri = 'https://opensource.org/licenses/MIT' + ReleaseNotes = '' + Prerelease = '' + } + } +} diff --git a/src/PSModuleUtils.psm1 b/src/PSModuleUtils.psm1 deleted file mode 100644 index bd3f93b..0000000 --- a/src/PSModuleUtils.psm1 +++ /dev/null @@ -1,9 +0,0 @@ -#Requires -Modules @{ ModuleName = 'Pester'; ModuleVersion = '5.0'; MaximumVersion = '5.99.99' }, @{ ModuleName = 'PSScriptAnalyzer'; MaximumVersion = '1.99.99' } -[System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidLongLines', '')] -[CmdletBinding()] -param () - -Get-ChildItem -Path "$PSScriptRoot/public" -Filter '*.ps1' -Exclude '*.Tests.ps1' -File -Recurse | - ForEach-Object -Process { - . $_.FullName - } diff --git a/src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 b/src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 new file mode 100644 index 0000000..c1a12ac --- /dev/null +++ b/src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 @@ -0,0 +1,32 @@ +<# +.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 +} diff --git a/src/Private/Get-PSModuleGitMetadata.ps1 b/src/Private/Get-PSModuleGitMetadata.ps1 new file mode 100644 index 0000000..c88acac --- /dev/null +++ b/src/Private/Get-PSModuleGitMetadata.ps1 @@ -0,0 +1,74 @@ +<# +.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 + } +} diff --git a/src/Private/Get-PSModulePublishedManifest.ps1 b/src/Private/Get-PSModulePublishedManifest.ps1 new file mode 100644 index 0000000..e575040 --- /dev/null +++ b/src/Private/Get-PSModulePublishedManifest.ps1 @@ -0,0 +1,56 @@ +<# +.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 + } +} diff --git a/src/Private/Invoke-PSModuleAnalyzerCasingWorkaround.ps1 b/src/Private/Invoke-PSModuleAnalyzerCasingWorkaround.ps1 new file mode 100644 index 0000000..d935d27 --- /dev/null +++ b/src/Private/Invoke-PSModuleAnalyzerCasingWorkaround.ps1 @@ -0,0 +1,125 @@ +<# +.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 +The PSScriptAnalyzer settings file to use. + +.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)] + [String]$Settings, + + [Switch]$Recurse, + + [String[]]$Severity, + + [Switch]$EnableExit, + + [Switch]$ReportSummary, + + [Switch]$Fix + ) + + $recursiveAnalyzerArguments = @{} + foreach ($argument in $PSBoundParameters.GetEnumerator()) { + $recursiveAnalyzerArguments[$argument.Key] = $argument.Value + } + + $settingsData = 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) { + $settingsData.Rules.PSUseCorrectCasing.Enable = $false + $recursiveAnalyzerArguments.Settings = $settingsData + $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 -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 + } +} diff --git a/src/Private/Resolve-PSModuleGitRemote.ps1 b/src/Private/Resolve-PSModuleGitRemote.ps1 new file mode 100644 index 0000000..263a91d --- /dev/null +++ b/src/Private/Resolve-PSModuleGitRemote.ps1 @@ -0,0 +1,110 @@ +<# +.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 '^(?:[^@/:]+@)?(?[^:/]+):(?.+)$') { + $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 + } +} diff --git a/src/Public/Build-PSModule.ps1 b/src/Public/Build-PSModule.ps1 new file mode 100644 index 0000000..504a664 --- /dev/null +++ b/src/Public/Build-PSModule.ps1 @@ -0,0 +1,158 @@ +<# +.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 that must remain separate in the published module, such as assemblies, schemas, +templates, settings, and localized content, 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 ".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. The source manifest is expected at "/.psd1". + +.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 "//". + +.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 +Files or directories to copy recursively into the built module without compilation. Paths are relative +to the source directory unless absolute. Use purpose-specific directories such as Assemblies, bin, +Settings, Schemas, Templates, Resources, or culture names such as en-US. + +.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 -Name 'MyModule' -Version '2.0.0' -SourceDirectory "$PWD/src" + +.NOTES +Requires the ModuleBuilder and Metadata modules. +#> +function Build-PSModule { + [CmdletBinding()] + [OutputType([System.IO.FileInfo])] + param ( + [String]$Name = 'PSModule', + [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' + + $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." + } + + $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 + } + + if ($CopyPaths.Count -gt 0) { + $buildModule['CopyPaths'] = $CopyPaths + } + + 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 + $null = Import-Module -Name $manifestPath -Force -PassThru -ErrorAction Stop + Get-Item -Path $manifestPath +} diff --git a/src/Public/Export-PSModuleAnalyzerSettings.ps1 b/src/Public/Export-PSModuleAnalyzerSettings.ps1 new file mode 100644 index 0000000..cc85efc --- /dev/null +++ b/src/Public/Export-PSModuleAnalyzerSettings.ps1 @@ -0,0 +1,74 @@ +<# +.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 + } + } +} diff --git a/src/Public/Invoke-PSModuleAnalyzer.ps1 b/src/Public/Invoke-PSModuleAnalyzer.ps1 new file mode 100644 index 0000000..ee325f6 --- /dev/null +++ b/src/Public/Invoke-PSModuleAnalyzer.ps1 @@ -0,0 +1,59 @@ +<# +.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. + +.PARAMETER SourceDirectory +The directory to analyze. + +.PARAMETER Settings +The settings file to use. Defaults to the bundled PSScriptAnalyzerSettings.psd1, resolved for both a +source checkout and a built module layout. + +.PARAMETER Fix +Whether to fix the issues found. + +.PARAMETER NoExit +Returns analyzer diagnostics without exiting the caller when violations are found. Use this when another +command needs to process the diagnostics, such as converting them to SARIF. + +.OUTPUTS +Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord + +.EXAMPLE +Invoke-PSModuleAnalyzer -SourceDirectory $PWD/src -Fix + +.NOTES +N/A +#> +function Invoke-PSModuleAnalyzer { + [CmdletBinding()] + [OutputType('Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord')] + param ( + [String]$SourceDirectory = "$PWD/src", + [String]$Settings = (Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $PSScriptRoot), + [Switch]$Fix, + [Switch]$NoExit + ) + + $scriptAnalyzerArgs = @{ + Path = $SourceDirectory + Settings = $Settings + Recurse = $true + Severity = 'Error', 'Warning', 'Information' + EnableExit = (-not $Fix -and -not $NoExit) + 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 +} diff --git a/src/Public/New-PSModuleManifest.ps1 b/src/Public/New-PSModuleManifest.ps1 new file mode 100644 index 0000000..4ba9347 --- /dev/null +++ b/src/Public/New-PSModuleManifest.ps1 @@ -0,0 +1,153 @@ +<# +.SYNOPSIS +Generates or migrates a hand-authored source module manifest template for use with Build-PSModule. + +.DESCRIPTION +Writes a source manifest template (".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 ".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 + } +} diff --git a/src/public/Publish-PSModule.ps1 b/src/Public/Publish-PSModule.ps1 similarity index 100% rename from src/public/Publish-PSModule.ps1 rename to src/Public/Publish-PSModule.ps1 diff --git a/src/public/Test-PSModule.ps1 b/src/Public/Test-PSModule.ps1 similarity index 71% rename from src/public/Test-PSModule.ps1 rename to src/Public/Test-PSModule.ps1 index acecb30..680dc5e 100644 --- a/src/public/Test-PSModule.ps1 +++ b/src/Public/Test-PSModule.ps1 @@ -10,7 +10,10 @@ and runs Pester with a configuration optimized for running in a CI pipeline. The name of the module. .PARAMETER SourceDirectory -The source directory of the module. Should be a nested directory that doesn't contain and build scripts. +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 Exclude The directories to exclude from testing and code coverage. @@ -19,7 +22,7 @@ The directories to exclude from testing and code coverage. The tag to filter tests by. .EXAMPLE -Test-PSModule -Name 'MyModule' -SourceDirectory "$PWD/src" -Tag 'Unit' +Test-PSModule -Name 'MyModule' -SourceDirectory "$PWD/src" -TestPath "$PWD/tests" -Tag 'Unit' .NOTES N/A @@ -29,23 +32,25 @@ function Test-PSModule { param ( [String]$Name = 'PSModule', [String]$SourceDirectory = "$PWD/src", + [String]$TestPath = "$PWD/tests", [String[]]$Exclude, [String[]]$Tag ) - $testFiles = Get-ChildItem -Path $SourceDirectory -Filter '*.Tests.ps1' -Recurse + $testFiles = Get-ChildItem -Path $TestPath -Filter '*.Tests.ps1' -Recurse -ErrorAction SilentlyContinue if (-not $testFiles) { - Write-Warning -Message "No test files found in $SourceDirectory" + 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 = $SourceDirectory + Path = $TestPath ExcludePath = $Exclude } CodeCoverage = @{ Enabled = $true + Path = $SourceDirectory OutputPath = 'tests/coverage.xml' } TestResult = @{ @@ -57,11 +62,9 @@ function Test-PSModule { } } if ($Tag) { - $config.Filter.Tag = 'Unit' + $config.Filter.Tag = $Tag } - # TODO: Remove after implementing test result publishing - $config.Run.Exit = $true $config.Run.Throw = $true Write-Verbose -Message 'Running Pester tests with the following configuration:' diff --git a/src/private/PSScriptAnalyzerSettings.psd1 b/src/Settings/PSScriptAnalyzerSettings.psd1 similarity index 76% rename from src/private/PSScriptAnalyzerSettings.psd1 rename to src/Settings/PSScriptAnalyzerSettings.psd1 index 2552102..93c4561 100644 --- a/src/private/PSScriptAnalyzerSettings.psd1 +++ b/src/Settings/PSScriptAnalyzerSettings.psd1 @@ -16,6 +16,13 @@ # Severity = @('Error', 'Warning') + # Optional custom rule settings. Uncomment and adjust these when loading + # rules that are not bundled with PSScriptAnalyzer. + + # CustomRulePath = @('/path/to/custom/rules') + # IncludeDefaultRules = $true + # RecurseCustomRulePath = $true + # Analyze **only** the following rules. Use IncludeRules when you want # to invoke only a small subset of the default rules. @@ -59,8 +66,10 @@ Enable = $true } PSAvoidOverwritingBuiltInCmdlets = @{ - # core-6.1.0-windows is the newest profile bundled with PSScriptAnalyzer. - PowerShellVersion = @('core-6.1.0-windows') + PowerShellVersion = @( + 'desktop-5.1.14393.206-windows' + 'core-6.1.0-windows' + ) } PSAvoidSemicolonsAsLineTerminators = @{ Enable = $true @@ -115,8 +124,20 @@ CommandsToTraverse = @() } PSUseCompatibleCmdlets = @{ - # core-6.1.0-windows is the newest profile bundled with PSScriptAnalyzer. - compatibility = @('core-6.1.0-windows') + compatibility = @( + 'desktop-5.1.14393.206-windows' + 'core-6.1.0-windows' + ) + } + PSUseCompatibleCommands = @{ + Enable = $true + TargetProfiles = @( + 'win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework' + 'win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core' + ) + # ProfileDirPath = '/path/to/compatibility/profiles' + # Pester is a required module, so its version is controlled by each consuming module. + IgnoreCommands = @('Invoke-Pester') } PSUseCompatibleSyntax = @{ Enable = $true @@ -125,6 +146,19 @@ '5.1' ) } + PSUseCompatibleTypes = @{ + Enable = $true + TargetProfiles = @( + 'win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework' + 'win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core' + ) + # ProfileDirPath = '/path/to/compatibility/profiles' + IgnoreTypes = @() + } + PSUseConstrainedLanguageMode = @{ + Enable = $false + IgnoreSignatures = @() + } PSUseConsistentParameterSetName = @{ Enable = $true } @@ -150,16 +184,17 @@ IgnoreAssignmentOperatorInsideHashTable = $true } PSUseCorrectCasing = @{ - # Throws when command discovery encounters some Crescendo-generated modules. - # Disable until fixed. - Enable = $false + Enable = $true + CheckCommands = $true + CheckKeyword = $true + CheckOperator = $true } PSUseSingleValueFromPipelineParameter = @{ Enable = $true } PSUseSingularNouns = @{ Enable = $true - NounAllowList = 'Data', 'Windows' + NounAllowList = 'Data', 'Metadata', 'Settings', 'Windows' } } } diff --git a/src/public/Build-PSModule.Tests.ps1 b/src/public/Build-PSModule.Tests.ps1 deleted file mode 100644 index 7ba2613..0000000 --- a/src/public/Build-PSModule.Tests.ps1 +++ /dev/null @@ -1,22 +0,0 @@ -# Pester declares parameters in lowercase (e.g. -name on It). -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] -param () - -Describe 'Integration Tests' -Tag 'Integration' { - BeforeAll { - Import-Module -Name "$PSScriptRoot/../PSModuleUtils.psm1" -Force - } - - It 'should build a versioned module' { - $BuildPSModule = @{ - Name = 'PSModuleUtils' - Version = '1.0.0-pester' - } - - Build-PSModule @BuildPSModule -SourceDirectory "$PSScriptRoot/.." -OutputDirectory "$TestDrive/out" - - "$TestDrive/out/PSModuleUtils/1.0.0-pester/PSModuleUtils.psd1" | Should -Exist - { Import-Module -Name "$TestDrive/out/PSModuleUtils/1.0.0-pester/PSModuleUtils.psd1" -Force } | - Should -Not -Throw - } -} diff --git a/src/public/Build-PSModule.ps1 b/src/public/Build-PSModule.ps1 deleted file mode 100644 index e747d06..0000000 --- a/src/public/Build-PSModule.ps1 +++ /dev/null @@ -1,194 +0,0 @@ -<# -.SYNOPSIS -Builds a PowerShell module formatted like the ones located at github.com/thisjustin816. - -.DESCRIPTION -Builds a PowerShell module formatted like the ones located at github.com/thisjustin816. -- Moves all public functions to a single .psm1 file and all private functions to a private folder. -- Removes any init blocks outside of the function. -- Formats the private function dot sources for the expected folder structure. -- Creates a module manifest. - -.PARAMETER Name -The name of the module. - -.PARAMETER Version -The version of the module. - -.PARAMETER Description -The description of the module. - -.PARAMETER Guid -The GUID of the module. If not provided it will look for the GUID in the PSGallery, or generate it. - -.PARAMETER Tags -The tags for the module. - -.PARAMETER LicenseUri -The URL for the repo's license. - -.PARAMETER SourceDirectory -The source directory of the module. Should be a nested directory that doesn't contain and build scripts. - -.PARAMETER OutputDirectory -The directory to output the .psm1 module and .psd1 manifest. - -.PARAMETER FixScriptAnalyzer -Whether to fix the ScriptAnalyzer issues. - -.EXAMPLE -$BuildPSModule = @{ - Name = 'MyModule' - Version = '1.0.0' - Description = 'A PowerShell module.' - Tags = ('PSEdition_Desktop', 'PSEdition_Core') -} -Build-PSModule @BuildPSModule - -.NOTES -N/A -#> -function Build-PSModule { - [System.Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingInvokeExpression', '')] - [CmdletBinding()] - param ( - [String]$Name = 'PSModule', - [String]$Version = '0.0.1', - [String]$Description = 'A PowerShell module.', - [String]$Guid, - [String[]]$Tags = @('PSEdition_Desktop', 'PSEdition_Core', 'Windows'), - [String]$LicenseUri = 'https://opensource.org/licenses/MIT', - [String]$SourceDirectory = "$PWD/src", - [String]$OutputDirectory = "$PWD/out", - [Switch]$FixScriptAnalyzer - ) - - Write-Host -Object 'Building with the following parameters:' - Write-Host -Object ( $PSBoundParameters | Out-String) - - Remove-Item -Path $OutputDirectory -Recurse -Force -ErrorAction SilentlyContinue - $ModuleOutputDirectory = "$OutputDirectory/$Name/$Version" - - $null = New-Item -Path "$ModuleOutputDirectory/$name.psm1" -ItemType File -Force - $functionNames = @() - $moduleContent = @() - Get-ChildItem -Path "$SourceDirectory/public" -Filter '*.ps1' -Exclude '*.Tests.ps1' -File -Recurse | - ForEach-Object -Process { - $functionName = $_.BaseName - Write-Host -Object "Building function $functionName..." - - $functionNames += $functionName - $functionContent = Get-Content -Path $_.FullName - $originalFunctionContent = $functionContent - - # Remove any init blocks outside of the function - $startIndex = ( - $functionContent.IndexOf('<#'), - $functionContent.IndexOf($functionContent -match "function $functionName")[0] - ) | Where-Object -FilterScript { $_ -ge 0 } | Sort-Object | Select-Object -First 1 - $functionContent = $functionContent[$startIndex..($functionContent.Length - 1)] - - # Format the private function dot sources for the expected folder structure - $functionContent = $functionContent -replace '\$PSScriptRoot/\.\./(\.\./)?private', '$PSScriptRoot/private' - - Write-Host -Object ( - Compare-Object -ReferenceObject $functionContent -DifferenceObject $originalFunctionContent | - Format-Table | - Out-String - ) - $moduleContent += '' - $moduleContent += $functionContent - } - - $srcModuleContent = Get-Content -Path "$SourceDirectory\$Name.psm1" -Raw - $startIndex = $srcModuleContent.IndexOf('Get-ChildItem') - $subString = $srcModuleContent.Substring($startIndex) - $braceIndex = $subString.IndexOf('}') - $moduleScriptContent = $subString.Substring($braceIndex + 1) - if ($moduleScriptContent) { - $moduleContent += $moduleScriptContent - } - - $moduleContent | Set-Content -Path "$ModuleOutputDirectory/$name.psm1" -Force - $null = New-Item -Path "$ModuleOutputDirectory/private" -ItemType Directory -Force - Get-ChildItem -Path "$SourceDirectory/private" -Exclude '*.Tests.ps1' | - Copy-Item -Destination "$ModuleOutputDirectory/private" -Recurse -Force - - $manifestPath = "$ModuleOutputDirectory/$Name.psd1" - $repoUrl = ( & git config --get remote.origin.url ) - $companyName = if ($repoUrl -match 'github') { - $repoUrl.Split('/')[3] - } - elseif ($repoUrl -match 'dev\.azure') { - $repoUrl.Split('/')[3] - } - else { - $env:USERDOMAIN - } - $projectUri = $repoUrl.Replace($companyName + '@', '').Replace('.git', '') - - if (-not $Guid) { - $publishedModuleGuid = $null - $guidLookupPath = Join-Path -Path $env:TEMP -ChildPath "psmodule-guid-$(( New-Guid ).Guid)" - try { - New-Item -ItemType Directory -Path $guidLookupPath -Force | Out-Null - Save-PSResource -Name $Name -Repository PSGallery -Path $guidLookupPath -TrustRepository -ErrorAction SilentlyContinue - $publishedManifest = Get-ChildItem -Path $guidLookupPath -Filter "$Name.psd1" -Recurse | Select-Object -First 1 - if ($publishedManifest) { - $publishedModuleGuid = (Import-PowerShellDataFile -Path $publishedManifest.FullName).Guid - } - } - finally { - Remove-Item -Path $guidLookupPath -Recurse -Force -ErrorAction SilentlyContinue - } - $Guid = if ($publishedModuleGuid) { - $publishedModuleGuid - } - else { - ( New-Guid ).Guid - } - } - - $requiredModulesStatement = $srcModuleContent.Split("`n") | - Where-Object -FilterScript { $_ -match '#Requires' } - $requiredModules = (($requiredModulesStatement -split '-Modules ')[1] -split ',').Trim() | - ForEach-Object { - if ($_ -match '@{') { - Invoke-Expression -Command $_ - } - else { - $_ - } - } - $moduleVersion, $modulePrerelease = $Version -split '-', 2 - $newModuleManifest = @{ - Path = $manifestPath - Author = (( & git log --format='%aN' -- . | Sort-Object -Unique ) -join ', ') - CompanyName = $companyName - Copyright = "(c) $( Get-Date -Format yyyy ) $companyName. All rights reserved." - RootModule = "$Name.psm1" - ModuleVersion = $moduleVersion - Guid = $guid - Description = $Description - PowerShellVersion = 5.1 - FunctionsToExport = $functionNames - CompatiblePSEditions = ('Desktop', 'Core') - Tags = $Tags - ProjectUri = $projectUri - LicenseUri = $LicenseUri - ReleaseNotes = ( git log -1 --pretty=%B )[0] - } - if ($requiredModules) { - $newModuleManifest['RequiredModules'] = $requiredModules - } - if ($modulePrerelease) { - $newModuleManifest['Prerelease'] = $modulePrerelease - } - Write-Host -Object 'Creating module manifest...' - Write-Host -Object ( $newModuleManifest | Out-String ) - New-ModuleManifest @newModuleManifest - Get-Item -Path $manifestPath - - Get-Module -Name $Name -All | Remove-Module -Force -ErrorAction SilentlyContinue - Import-Module -Name $manifestPath -Force -PassThru -} diff --git a/src/public/Invoke-PSModuleAnalyzer.ps1 b/src/public/Invoke-PSModuleAnalyzer.ps1 deleted file mode 100644 index 53d15a0..0000000 --- a/src/public/Invoke-PSModuleAnalyzer.ps1 +++ /dev/null @@ -1,45 +0,0 @@ -<# -.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. - -.PARAMETER SourceDirectory -The directory to analyze. - -.PARAMETER Settings -The settings file to use. Defaults to internal custom file. - -.PARAMETER Fix -Whether to fix the issues found. - -.EXAMPLE -Invoke-PSModuleAnalyzer -SourceDirectory $PWD/src -Fix - -.NOTES -N/A -#> -function Invoke-PSModuleAnalyzer { - [CmdletBinding()] - param ( - [String]$SourceDirectory = "$PWD/src", - [String]$Settings = "$PSScriptRoot/../private/PSScriptAnalyzerSettings.psd1", - [Switch]$Fix - ) - - $scriptAnalyzerArgs = @{ - Path = $SourceDirectory - Settings = $Settings - Recurse = $true - Severity = 'Information' - EnableExit = (-not $Fix) - ReportSummary = $true - } - - if ($Fix) { - $scriptAnalyzerArgs.Fix = $true - } - - Invoke-ScriptAnalyzer @scriptAnalyzerArgs -} diff --git a/tests/Build-PSModule.Tests.ps1 b/tests/Build-PSModule.Tests.ps1 new file mode 100644 index 0000000..91dd860 --- /dev/null +++ b/tests/Build-PSModule.Tests.ps1 @@ -0,0 +1,79 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Integration Tests' -Tag 'Integration' { + BeforeAll { + Import-Module -Name 'ModuleBuilder', 'Metadata' -ErrorAction Stop + . "$PSScriptRoot/../src/Private/Get-PSModuleGitMetadata.ps1" + . "$PSScriptRoot/../src/Public/Build-PSModule.ps1" + } + + It 'should build a versioned module' { + $BuildPSModule = @{ + Name = 'PSModuleUtils' + Version = '1.0.0-pester' + CopyPaths = 'Settings' + } + + Build-PSModule @BuildPSModule -SourceDirectory "$PSScriptRoot/../src" -OutputDirectory "$TestDrive/out" -SkipGitMetadata + + # ModuleBuilder strips the prerelease label from the output folder name. + "$TestDrive/out/PSModuleUtils/1.0.0/PSModuleUtils.psd1" | Should -Exist + "$TestDrive/out/PSModuleUtils/1.0.0/Settings/PSScriptAnalyzerSettings.psd1" | Should -Exist + "$TestDrive/out/PSModuleUtils/1.0.0/private" | Should -Not -Exist + { Import-Module -Name "$TestDrive/out/PSModuleUtils/1.0.0/PSModuleUtils.psd1" -Force } | + Should -Not -Throw + } + + It 'should preserve purpose-specific asset directories' { + $sourceDirectory = Join-Path -Path $TestDrive -ChildPath 'AssetModule/src' + $assembliesDirectory = Join-Path -Path $sourceDirectory -ChildPath 'Assemblies' + $schemasDirectory = Join-Path -Path $sourceDirectory -ChildPath 'Schemas' + $publicDirectory = Join-Path -Path $sourceDirectory -ChildPath 'Public' + $null = New-Item -ItemType Directory ` + -Path $assembliesDirectory, $schemasDirectory, $publicDirectory ` + -Force + $binaryFixture = [Byte[]](0, 1, 2, 13, 10, 255) + Set-Content -Path "$assembliesDirectory/Example.dll" -Value $binaryFixture -AsByteStream + Set-Content -Path "$schemasDirectory/Example.schema.json" -Value '{}' + Set-Content -Path "$publicDirectory/Get-Example.ps1" -Value 'function Get-Example { $true }' + New-ModuleManifest -Path "$sourceDirectory/AssetModule.psd1" ` + -RootModule 'AssetModule.psm1' ` + -ModuleVersion '1.0.0' ` + -FunctionsToExport @() + + Build-PSModule -Name 'AssetModule' ` + -SourceDirectory $sourceDirectory ` + -OutputDirectory "$TestDrive/assets-out" ` + -CopyPaths 'Assemblies', 'Schemas' ` + -SkipGitMetadata + + "$TestDrive/assets-out/AssetModule/1.0.0/Assemblies/Example.dll" | Should -Exist + "$TestDrive/assets-out/AssetModule/1.0.0/Schemas/Example.schema.json" | Should -Exist + $copiedBinary = Get-Content ` + -Path "$TestDrive/assets-out/AssetModule/1.0.0/Assemblies/Example.dll" ` + -AsByteStream ` + -Raw + [Convert]::ToBase64String($copiedBinary) | Should -Be ([Convert]::ToBase64String($binaryFixture)) + } + + It 'should reject noncanonical source directory casing' { + $sourceDirectory = Join-Path -Path $TestDrive -ChildPath 'CaseModule/src' + $publicDirectory = Join-Path -Path $sourceDirectory -ChildPath 'public' + $null = New-Item -ItemType Directory -Path $publicDirectory -Force + Set-Content -Path "$publicDirectory/Get-Example.ps1" -Value 'function Get-Example { $true }' + New-ModuleManifest -Path "$sourceDirectory/CaseModule.psd1" ` + -RootModule 'CaseModule.psm1' ` + -ModuleVersion '1.0.0' ` + -FunctionsToExport @() + + { + Build-PSModule ` + -Name 'CaseModule' ` + -SourceDirectory $sourceDirectory ` + -OutputDirectory "$TestDrive/case-out" ` + -SkipGitMetadata + } | Should -Throw "*Source directory 'public' must be named 'Public'.*" + } +} diff --git a/tests/Export-PSModuleAnalyzerSettings.Tests.ps1 b/tests/Export-PSModuleAnalyzerSettings.Tests.ps1 new file mode 100644 index 0000000..546829b --- /dev/null +++ b/tests/Export-PSModuleAnalyzerSettings.Tests.ps1 @@ -0,0 +1,89 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Export-PSModuleAnalyzerSettings' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 + . $PSScriptRoot/../src/Public/Export-PSModuleAnalyzerSettings.ps1 + $script:bundledSettingsPath = ( + Resolve-Path -Path $PSScriptRoot/../src/Settings/PSScriptAnalyzerSettings.psd1 + ).Path + } + + BeforeEach { + $script:workingDirectory = Join-Path -Path $TestDrive -ChildPath (New-Guid) + $null = New-Item -ItemType Directory -Path $script:workingDirectory + Push-Location -Path $script:workingDirectory + } + + AfterEach { + Pop-Location + } + + It 'should export to the current directory by default without emitting output' { + $output = @(Export-PSModuleAnalyzerSettings) + $expectedPath = Join-Path -Path $script:workingDirectory -ChildPath 'PSScriptAnalyzerSettings.psd1' + + $expectedPath | Should -Exist + $output.Count | Should -Be 0 + } + + It 'should copy the bundled settings verbatim to an explicit path' { + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'Analyzer.psd1' + + Export-PSModuleAnalyzerSettings -Path $destinationPath + + $sourceBytes = Get-Content -LiteralPath $script:bundledSettingsPath -AsByteStream -Raw + $destinationBytes = Get-Content -LiteralPath $destinationPath -AsByteStream -Raw + [Convert]::ToBase64String($destinationBytes) | + Should -BeExactly ([Convert]::ToBase64String($sourceBytes)) + } + + It 'should require Force before replacing an existing file' { + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'Analyzer.psd1' + Set-Content -LiteralPath $destinationPath -Value 'custom settings' + + { Export-PSModuleAnalyzerSettings -Path $destinationPath } | + Should -Throw '*already exists*' + Get-Content -LiteralPath $destinationPath -Raw | Should -Match 'custom settings' + + Export-PSModuleAnalyzerSettings -Path $destinationPath -Force + $sourceBytes = Get-Content -LiteralPath $script:bundledSettingsPath -AsByteStream -Raw + $destinationBytes = Get-Content -LiteralPath $destinationPath -AsByteStream -Raw + [Convert]::ToBase64String($destinationBytes) | + Should -BeExactly ([Convert]::ToBase64String($sourceBytes)) + } + + It 'should return the exported file when PassThru is specified' { + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'Analyzer.psd1' + + $result = Export-PSModuleAnalyzerSettings -Path $destinationPath -PassThru + + $result | Should -BeOfType ([System.IO.FileInfo]) + $result.FullName | Should -BeExactly $destinationPath + } + + It 'should honor WhatIf without creating a file' { + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'Analyzer.psd1' + + Export-PSModuleAnalyzerSettings -Path $destinationPath -WhatIf + + $destinationPath | Should -Not -Exist + } + + It 'should throw when the destination parent does not exist' { + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'missing/Analyzer.psd1' + + { Export-PSModuleAnalyzerSettings -Path $destinationPath } | + Should -Throw '*parent directory*does not exist*' + } + + It 'should throw when the bundled settings file cannot be resolved' { + Mock Get-PSModuleAnalyzerSettingsPath {} + $destinationPath = Join-Path -Path $script:workingDirectory -ChildPath 'Analyzer.psd1' + + { Export-PSModuleAnalyzerSettings -Path $destinationPath } | + Should -Throw '*bundled PSScriptAnalyzer settings file could not be resolved*' + } +} diff --git a/tests/Get-PSModuleAnalyzerSettingsPath.Tests.ps1 b/tests/Get-PSModuleAnalyzerSettingsPath.Tests.ps1 new file mode 100644 index 0000000..c34c6e8 --- /dev/null +++ b/tests/Get-PSModuleAnalyzerSettingsPath.Tests.ps1 @@ -0,0 +1,27 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Unit Tests' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 + } + + It 'should resolve the source layout (Settings is one level up)' { + Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot "$PSScriptRoot/../src/Public" | Should -Exist + } + + It 'should resolve the built layout (Settings is a direct sibling)' { + $builtRoot = Join-Path -Path $TestDrive -ChildPath 'built' + $settingsDirectory = Join-Path -Path $builtRoot -ChildPath 'Settings' + $null = New-Item -ItemType Directory -Path $settingsDirectory -Force + Copy-Item -Path "$PSScriptRoot/../src/Settings/PSScriptAnalyzerSettings.psd1" ` + -Destination $settingsDirectory + + Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $builtRoot | Should -Exist + } + + It 'should return nothing when neither candidate exists' { + Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot $TestDrive | Should -BeNullOrEmpty + } +} diff --git a/tests/Get-PSModuleGitMetadata.Tests.ps1 b/tests/Get-PSModuleGitMetadata.Tests.ps1 new file mode 100644 index 0000000..da22ce6 --- /dev/null +++ b/tests/Get-PSModuleGitMetadata.Tests.ps1 @@ -0,0 +1,51 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Unit Tests' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Resolve-PSModuleGitRemote.ps1 + . $PSScriptRoot/../src/Private/Get-PSModuleGitMetadata.ps1 + } + + It 'should return metadata for a path inside a git working tree' { + $metadata = Get-PSModuleGitMetadata -Path "$PSScriptRoot/.." + + $metadata | Should -Not -BeNullOrEmpty + $metadata.Author | Should -Not -BeNullOrEmpty + $metadata.CompanyName | Should -Not -BeNullOrEmpty + $metadata.ProjectUri | Should -Not -BeNullOrEmpty + $metadata.ProjectUri | Should -Match '^https?://' + $metadata.ProjectUri | Should -Not -Match '@' + $metadata.ProjectUri | Should -Not -Match '\.git$' + } + + It 'should warn and return nothing for a path outside a git working tree' { + $outsidePath = Join-Path -Path $TestDrive -ChildPath 'not-a-repo' + New-Item -ItemType Directory -Path $outsidePath -Force | Out-Null + + $metadata = Get-PSModuleGitMetadata -Path $outsidePath -WarningVariable warnings -WarningAction SilentlyContinue + + $metadata | Should -BeNullOrEmpty + ( $warnings -join ' ' ) | Should -Match 'not inside a git working tree' + } + + It 'should warn and leave repository metadata blank when the remote cannot be resolved' { + Mock Resolve-PSModuleGitRemote { throw 'unsupported remote' } + $metadataParameters = @{ + Path = "$PSScriptRoot/.." + WarningVariable = 'warnings' + WarningAction = 'SilentlyContinue' + } + + $metadata = Get-PSModuleGitMetadata @metadataParameters + + $metadata.Author | Should -Not -BeNullOrEmpty + $metadata.CompanyName | Should -BeNullOrEmpty + $metadata.Copyright | Should -BeNullOrEmpty + $metadata.ProjectUri | Should -BeNullOrEmpty + ( $warnings -join ' ' ) | Should -Match ( + 'company name was not provided.*Git remote URL could not be resolved' + ) + } +} diff --git a/tests/Get-PSModulePublishedManifest.Tests.ps1 b/tests/Get-PSModulePublishedManifest.Tests.ps1 new file mode 100644 index 0000000..759c5cb --- /dev/null +++ b/tests/Get-PSModulePublishedManifest.Tests.ps1 @@ -0,0 +1,36 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Unit Tests' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Get-PSModulePublishedManifest.ps1 + } + + Context 'when a published version exists' { + BeforeEach { + Mock Save-PSResource { + $manifestDir = Join-Path -Path $Path -ChildPath 'TestModule/1.2.3' + New-Item -ItemType Directory -Path $manifestDir -Force | Out-Null + New-ModuleManifest -Path (Join-Path -Path $manifestDir -ChildPath 'TestModule.psd1') ` + -ModuleVersion '1.2.3' -Guid 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' -Description 'stub' + } + } + + It 'should return the published manifest' { + $manifest = Get-PSModulePublishedManifest -Name 'TestModule' -Repository 'PSGallery' + $manifest.Guid | Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + } + + Context 'when no published version exists' { + BeforeEach { + Mock Save-PSResource {} + } + + It 'should return nothing and not throw' { + { Get-PSModulePublishedManifest -Name 'NoSuchModule' -Repository 'PSGallery' } | Should -Not -Throw + Get-PSModulePublishedManifest -Name 'NoSuchModule' -Repository 'PSGallery' | Should -BeNullOrEmpty + } + } +} diff --git a/src/public/Invoke-PSModuleAnalyzer.Tests.ps1 b/tests/Invoke-PSModuleAnalyzer.Tests.ps1 similarity index 72% rename from src/public/Invoke-PSModuleAnalyzer.Tests.ps1 rename to tests/Invoke-PSModuleAnalyzer.Tests.ps1 index f8e01bb..427e816 100644 --- a/src/public/Invoke-PSModuleAnalyzer.Tests.ps1 +++ b/tests/Invoke-PSModuleAnalyzer.Tests.ps1 @@ -4,7 +4,9 @@ param () Describe 'Unit Tests' -Tag 'Unit' { BeforeAll { - . $PSScriptRoot/Invoke-PSModuleAnalyzer.ps1 + . $PSScriptRoot/../src/Private/Get-PSModuleAnalyzerSettingsPath.ps1 + . $PSScriptRoot/../src/Private/Invoke-PSModuleAnalyzerCasingWorkaround.ps1 + . $PSScriptRoot/../src/Public/Invoke-PSModuleAnalyzer.ps1 $cleanFile = Join-Path -Path $TestDrive -ChildPath 'Clean.ps1' "function Get-Clean { 'ok' }" | Set-Content -Path $cleanFile -Encoding utf8 @@ -37,17 +39,47 @@ Describe 'Unit Tests' -Tag 'Unit' { { Invoke-PSModuleAnalyzer -SourceDirectory $TestDrive -Fix } | Should -Not -Throw } - It 'should only pass Fix to PSScriptAnalyzer in fix mode' { - Mock Invoke-ScriptAnalyzer {} + It 'should declare analyzer diagnostic records as output' { + $outputTypeNames = (Get-Command Invoke-PSModuleAnalyzer).OutputType.Name + + $outputTypeNames | + Should -Contain 'Microsoft.Windows.PowerShell.ScriptAnalyzer.Generic.DiagnosticRecord' + } + + It 'should configure the workaround call and control analyzer exit behavior' { + Mock Invoke-PSModuleAnalyzerCasingWorkaround {} Invoke-PSModuleAnalyzer -SourceDirectory $TestDrive - Should -Invoke Invoke-ScriptAnalyzer -Exactly -Times 1 -ParameterFilter { - -not $PSBoundParameters.ContainsKey('Fix') -and $EnableExit -eq $true + Should -Invoke Invoke-PSModuleAnalyzerCasingWorkaround -Exactly -Times 1 -ParameterFilter { + -not $PSBoundParameters.ContainsKey('Fix') -and + $EnableExit -eq $true -and + $ReportSummary -eq $true -and + $Severity -contains 'Error' -and + $Severity -contains 'Warning' -and + $Severity -contains 'Information' -and + $ErrorAction -eq 'Stop' } Invoke-PSModuleAnalyzer -SourceDirectory $TestDrive -Fix - Should -Invoke Invoke-ScriptAnalyzer -Exactly -Times 1 -ParameterFilter { - $Fix -eq $true -and $EnableExit -eq $false + Should -Invoke Invoke-PSModuleAnalyzerCasingWorkaround -Exactly -Times 1 -ParameterFilter { + $Fix -eq $true -and + $EnableExit -eq $false -and + $ReportSummary -eq $true -and + $Severity -contains 'Error' -and + $Severity -contains 'Warning' -and + $Severity -contains 'Information' -and + $ErrorAction -eq 'Stop' + } + + Invoke-PSModuleAnalyzer -SourceDirectory $TestDrive -NoExit + Should -Invoke Invoke-PSModuleAnalyzerCasingWorkaround -Exactly -Times 1 -ParameterFilter { + -not $Fix -and + $EnableExit -eq $false -and + $ReportSummary -eq $true -and + $Severity -contains 'Error' -and + $Severity -contains 'Warning' -and + $Severity -contains 'Information' -and + $ErrorAction -eq 'Stop' } } @@ -212,8 +244,14 @@ $ScriptBlockCases $customSettingsFile = Join-Path -Path $customSettingsDir -ChildPath 'Clean.ps1' "function Get-Clean { 'ok' }" | Set-Content -Path $customSettingsFile -Encoding utf8 - $settings = Resolve-Path -Path "$PSScriptRoot/../private/PSScriptAnalyzerSettings.psd1" + $settings = Resolve-Path -Path "$PSScriptRoot/../src/Settings/PSScriptAnalyzerSettings.psd1" { Invoke-PSModuleAnalyzer -SourceDirectory $customSettingsDir -Settings $settings -Fix } | Should -Not -Throw } + + It 'should resolve the bundled settings file when running from source' { + Invoke-PSModuleAnalyzer -SourceDirectory $TestDrive -Fix + Get-PSModuleAnalyzerSettingsPath -CallerScriptRoot "$PSScriptRoot/../src/Public" | + Should -Exist + } } diff --git a/tests/Invoke-PSModuleAnalyzerCasingWorkaround.Tests.ps1 b/tests/Invoke-PSModuleAnalyzerCasingWorkaround.Tests.ps1 new file mode 100644 index 0000000..5052ffc --- /dev/null +++ b/tests/Invoke-PSModuleAnalyzerCasingWorkaround.Tests.ps1 @@ -0,0 +1,86 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Invoke-PSModuleAnalyzerCasingWorkaround' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Invoke-PSModuleAnalyzerCasingWorkaround.ps1 + $script:defaultSettingsPath = ( + Resolve-Path -Path $PSScriptRoot/../src/Settings/PSScriptAnalyzerSettings.psd1 + ).Path + } + + It 'should analyze casing one file at a time without mutating the caller arguments' { + $fixtureDir = Join-Path -Path $TestDrive -ChildPath 'CorrectCasingFixture' + $null = New-Item -ItemType Directory -Path $fixtureDir -Force + "function Get-First { 'first' }" | Set-Content -Path "$fixtureDir/First.ps1" + "function Get-Second { 'second' }" | Set-Content -Path "$fixtureDir/Second.psm1" + 'ignored' | Set-Content -Path "$fixtureDir/Ignored.txt" + $scriptAnalyzerArguments = @{ + Path = $fixtureDir + Settings = $script:defaultSettingsPath + Recurse = $true + Severity = 'Information' + EnableExit = $false + ReportSummary = $true + ErrorAction = 'Stop' + Fix = $true + } + Mock Invoke-ScriptAnalyzer {} + + Invoke-PSModuleAnalyzerCasingWorkaround @scriptAnalyzerArguments + + Should -Invoke Invoke-ScriptAnalyzer -Exactly -Times 2 -ParameterFilter { + $Settings.IncludeRules -contains 'PSUseCorrectCasing' -and + $Settings.Rules.PSUseCorrectCasing.CheckCommands -eq $true -and + $Settings.Rules.PSUseCorrectCasing.CheckKeyword -eq $true -and + $Settings.Rules.PSUseCorrectCasing.CheckOperator -eq $true -and + $Fix -eq $true -and + $Recurse -eq $false -and + $ReportSummary -eq $false -and + $EnableExit -eq $false -and + $ErrorAction -eq 'Stop' + } + Should -Invoke Invoke-ScriptAnalyzer -Exactly -Times 1 -ParameterFilter { + $Settings.Rules.PSUseCorrectCasing.Enable -eq $false -and + $Recurse -eq $true -and + $ReportSummary -eq $true -and + $ErrorAction -eq 'Stop' + } + $scriptAnalyzerArguments.Settings | Should -BeExactly $script:defaultSettingsPath + } + + It 'should invoke PSScriptAnalyzer unchanged when command casing does not need the workaround' { + $settingsPath = Join-Path -Path $TestDrive -ChildPath 'DisabledCasingSettings.psd1' + @' +@{ + Rules = @{ + PSUseCorrectCasing = @{ + Enable = $false + } + } +} +'@ | Set-Content -Path $settingsPath + $scriptAnalyzerArguments = @{ + Path = $TestDrive + Settings = $settingsPath + Recurse = $true + Severity = 'Information' + EnableExit = $false + ReportSummary = $true + ErrorAction = 'Stop' + Fix = $true + } + Mock Invoke-ScriptAnalyzer {} + + Invoke-PSModuleAnalyzerCasingWorkaround @scriptAnalyzerArguments + + Should -Invoke Invoke-ScriptAnalyzer -Exactly -Times 1 -ParameterFilter { + $Settings -eq $settingsPath -and + $Recurse -eq $true -and + $ReportSummary -eq $true -and + $Fix -eq $true -and + $ErrorAction -eq 'Stop' + } + } +} diff --git a/tests/New-PSModuleManifest.Tests.ps1 b/tests/New-PSModuleManifest.Tests.ps1 new file mode 100644 index 0000000..eae2ad1 --- /dev/null +++ b/tests/New-PSModuleManifest.Tests.ps1 @@ -0,0 +1,100 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Unit Tests' -Tag 'Unit' { + BeforeAll { + Import-Module -Name 'JBUtils', 'Metadata' -ErrorAction Stop + . "$PSScriptRoot/../src/Private/Get-PSModulePublishedManifest.ps1" + . "$PSScriptRoot/../src/Public/New-PSModuleManifest.ps1" + } + + Context 'when no manifest exists yet' { + BeforeEach { + $script:sourceDir = Join-Path -Path $TestDrive -ChildPath ( New-Guid ).Guid + } + + It 'should write a manifest with the pre-seeded Prerelease/ReleaseNotes keys' { + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup + + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + $manifestPath | Should -Exist + + $manifest = Import-PowerShellDataFile -Path $manifestPath + $manifest.RootModule | Should -Be 'TestModule.psm1' + $manifest.FunctionsToExport | Should -BeNullOrEmpty + $manifest.PrivateData.PSData.Prerelease | Should -Be '' + $manifest.PrivateData.PSData.ReleaseNotes | Should -Be '' + } + + It 'should write an explicit module version' { + New-PSModuleManifest ` + -Name 'TestModule' ` + -SourceDirectory $script:sourceDir ` + -ModuleVersion '2.3.4' ` + -SkipRepositoryLookup + + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + (Import-PowerShellDataFile -Path $manifestPath).ModuleVersion.ToString() | + Should -Be '2.3.4' + } + + It 'should write the manifest with consistent line endings (regression: no CRLF tail on an LF body)' { + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + + $bytes = [IO.File]::ReadAllBytes($manifestPath) + $sawBareLf = $false + $sawCrlf = $false + for ($i = 0; $i -lt $bytes.Length; $i++) { + if ($bytes[$i] -eq 10) { + if ($i -gt 0 -and $bytes[$i - 1] -eq 13) { $sawCrlf = $true } else { $sawBareLf = $true } + } + } + + $sawBareLf | Should -BeTrue -Because 'ConvertTo-Psd1 joins lines with a bare LF' + $sawCrlf | Should -BeFalse -Because 'a mixed LF-body/CRLF-tail file trips PSScriptAnalyzer''s line-ending detection' + } + + It 'should produce a manifest Update-Metadata can patch' { + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + + { Update-Metadata -Path $manifestPath -PropertyName 'PrivateData.PSData.ReleaseNotes' -Value 'notes' -ErrorAction Stop } | + Should -Not -Throw + (Import-PowerShellDataFile -Path $manifestPath).PrivateData.PSData.ReleaseNotes | Should -Be 'notes' + } + + It 'should reuse the GUID of a previously published module' { + Mock Get-PSModulePublishedManifest { @{ Guid = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' } } + + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + (Import-PowerShellDataFile -Path $manifestPath).Guid | Should -Be 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee' + } + + It 'should not write anything with -WhatIf' { + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup -WhatIf + Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' | Should -Not -Exist + } + } + + Context 'when a manifest already exists' { + BeforeEach { + $script:sourceDir = Join-Path -Path $TestDrive -ChildPath ( New-Guid ).Guid + New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup + } + + It 'should throw without -Force' { + { New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -SkipRepositoryLookup } | + Should -Throw '*already exists*' + } + + It 'should overwrite with -Force' { + { New-PSModuleManifest -Name 'TestModule' -SourceDirectory $script:sourceDir -Description 'updated' -SkipRepositoryLookup -Force } | + Should -Not -Throw + $manifestPath = Join-Path -Path $script:sourceDir -ChildPath 'TestModule.psd1' + (Import-PowerShellDataFile -Path $manifestPath).Description | Should -Be 'updated' + } + } +} diff --git a/tests/PSModuleUtils.Module.Tests.ps1 b/tests/PSModuleUtils.Module.Tests.ps1 new file mode 100644 index 0000000..da41b1e --- /dev/null +++ b/tests/PSModuleUtils.Module.Tests.ps1 @@ -0,0 +1,39 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Module Validation' { + BeforeAll { + $script:builtManifest = Get-ChildItem -Path "$PSScriptRoot/../out/PSModuleUtils" -Filter 'PSModuleUtils.psd1' -Recurse -ErrorAction SilentlyContinue | + Sort-Object -Property FullName -Descending | + Select-Object -First 1 + } + + Context 'built module' { + It 'should have been built before running this test' { + $script:builtManifest | Should -Not -BeNullOrEmpty -Because 'Build-PSModule must run before Module Validation tests' + } + + It 'should not contain Pester test syntax' { + # Regression guard: ModuleBuilder inlines every .ps1 under its source folders, so a + # *.Tests.ps1 left inside Public/Private would leak Describe/It blocks into the built module. + $builtScript = Join-Path -Path $script:builtManifest.DirectoryName -ChildPath 'PSModuleUtils.psm1' + $builtScript | Should -Not -FileContentMatch '^Describe ' + } + + It 'should not export any package management functions' { + Get-Module -Name 'PSModuleUtils' -All | Remove-Module -Force -ErrorAction SilentlyContinue + $pkgMgmtFunctions = Get-Command -Module ( + 'PackageManagement', 'PowerShellGet', 'Microsoft.PowerShell.PSResourceGet' + ) + Import-Module -Name $script:builtManifest.FullName -Force + $moduleFunctions = Get-Command -Module 'PSModuleUtils' + foreach ($function in $pkgMgmtFunctions) { + foreach ($moduleFunction in $moduleFunctions) { + $moduleFunction.Name | Should -Not -Be $function.Name + } + } + Remove-Module -Name 'PSModuleUtils' -Force + } + } +} diff --git a/tests/PSScriptAnalyzerSettings.Tests.ps1 b/tests/PSScriptAnalyzerSettings.Tests.ps1 new file mode 100644 index 0000000..7e407ea --- /dev/null +++ b/tests/PSScriptAnalyzerSettings.Tests.ps1 @@ -0,0 +1,73 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'PSScriptAnalyzer settings' -Tag 'Unit' { + BeforeAll { + $script:settingsPath = "$PSScriptRoot/../src/Settings/PSScriptAnalyzerSettings.psd1" + $script:settings = Import-PowerShellDataFile -Path $script:settingsPath + $script:windowsCompatibilityProfiles = @( + 'win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework' + 'win-4_x64_10.0.18362.0_7.0.0_x64_3.1.2_core' + ) + $script:legacyCompatibilityProfiles = @( + 'desktop-5.1.14393.206-windows' + 'core-6.1.0-windows' + ) + } + + It 'should target Windows PowerShell and PowerShell Core with the legacy compatibility rules' { + ($script:settings.Rules.PSAvoidOverwritingBuiltInCmdlets.PowerShellVersion -join '|') | + Should -BeExactly ($script:legacyCompatibilityProfiles -join '|') + ($script:settings.Rules.PSUseCompatibleCmdlets.compatibility -join '|') | + Should -BeExactly ($script:legacyCompatibilityProfiles -join '|') + } + + It 'should configure compatible commands with every supported option' { + $rule = $script:settings.Rules.PSUseCompatibleCommands + + $rule.Enable | Should -BeTrue + ($rule.TargetProfiles -join '|') | Should -BeExactly ($script:windowsCompatibilityProfiles -join '|') + ($rule.IgnoreCommands -join '|') | Should -BeExactly 'Invoke-Pester' + } + + It 'should configure compatible types with every supported option' { + $rule = $script:settings.Rules.PSUseCompatibleTypes + + $rule.Enable | Should -BeTrue + ($rule.TargetProfiles -join '|') | Should -BeExactly ($script:windowsCompatibilityProfiles -join '|') + @($rule.IgnoreTypes).Count | Should -Be 0 + } + + It 'should expose constrained language configuration without enabling it by default' { + $rule = $script:settings.Rules.PSUseConstrainedLanguageMode + + $rule.Enable | Should -BeFalse + @($rule.IgnoreSignatures).Count | Should -Be 0 + } + + It 'should enable correct command casing' { + $rule = $script:settings.Rules.PSUseCorrectCasing + + $rule.Enable | Should -BeTrue + $rule.CheckCommands | Should -BeTrue + $rule.CheckKeyword | Should -BeTrue + $rule.CheckOperator | Should -BeTrue + } + + It 'should allow conventional collective nouns' { + $rule = $script:settings.Rules.PSUseSingularNouns + + $rule.Enable | Should -BeTrue + ($rule.NounAllowList -join '|') | Should -BeExactly 'Data|Metadata|Settings|Windows' + } + + It 'should retain commented examples for path-dependent settings' { + $settingsContent = Get-Content -Path $script:settingsPath -Raw + + $settingsContent | Should -Match '# CustomRulePath =' + $settingsContent | Should -Match '# IncludeDefaultRules =' + $settingsContent | Should -Match '# RecurseCustomRulePath =' + ([Regex]::Matches($settingsContent, '# ProfileDirPath =')).Count | Should -Be 2 + } +} diff --git a/src/public/Publish-PSModule.Tests.ps1 b/tests/Publish-PSModule.Tests.ps1 similarity index 97% rename from src/public/Publish-PSModule.Tests.ps1 rename to tests/Publish-PSModule.Tests.ps1 index 92b6a83..2ac44d9 100644 --- a/src/public/Publish-PSModule.Tests.ps1 +++ b/tests/Publish-PSModule.Tests.ps1 @@ -4,7 +4,7 @@ param () Describe 'Unit Tests' -Tag 'Unit' { BeforeAll { - . $PSScriptRoot/Publish-PSModule.ps1 + . $PSScriptRoot/../src/Public/Publish-PSModule.ps1 } Context 'when no versioned folder exists' { diff --git a/tests/Resolve-PSModuleGitRemote.Tests.ps1 b/tests/Resolve-PSModuleGitRemote.Tests.ps1 new file mode 100644 index 0000000..e8d3733 --- /dev/null +++ b/tests/Resolve-PSModuleGitRemote.Tests.ps1 @@ -0,0 +1,83 @@ +# Pester declares parameters in lowercase (e.g. -name on It). +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseCorrectCasing', '')] +param () + +Describe 'Unit Tests' -Tag 'Unit' { + BeforeAll { + . $PSScriptRoot/../src/Private/Resolve-PSModuleGitRemote.ps1 + } + + It 'should normalize ' -ForEach @( + @{ + Name = 'GitHub HTTPS' + Repository = 'https://github.com/example-org/module.git' + ProjectUri = 'https://github.com/example-org/module' + Organization = 'example-org' + } + @{ + Name = 'GitHub SCP-style SSH' + Repository = 'git@github.com:example-org/module.git' + ProjectUri = 'https://github.com/example-org/module' + Organization = 'example-org' + } + @{ + Name = 'credentialed Bitbucket HTTPS' + Repository = 'https://developer@bitbucket.org/example-workspace/module.git' + ProjectUri = 'https://bitbucket.org/example-workspace/module' + Organization = 'example-workspace' + } + @{ + Name = 'Bitbucket SCP-style SSH' + Repository = 'git@bitbucket.org:example-workspace/module.git' + ProjectUri = 'https://bitbucket.org/example-workspace/module' + Organization = 'example-workspace' + } + @{ + Name = 'GitLab subgroup HTTPS' + Repository = 'https://gitlab.com/example-group/platform/module.git' + ProjectUri = 'https://gitlab.com/example-group/platform/module' + Organization = 'example-group' + } + @{ + Name = 'Azure DevOps HTTPS' + Repository = 'https://developer@dev.azure.com/example-org/platform/_git/module' + ProjectUri = 'https://dev.azure.com/example-org/platform/_git/module' + Organization = 'example-org' + } + @{ + Name = 'Azure DevOps SSH' + Repository = 'git@ssh.dev.azure.com:v3/example-org/platform/module' + ProjectUri = 'https://dev.azure.com/example-org/platform/_git/module' + Organization = 'example-org' + } + @{ + Name = 'legacy Azure DevOps HTTPS' + Repository = 'https://example-org.visualstudio.com/platform/_git/module' + ProjectUri = 'https://example-org.visualstudio.com/platform/_git/module' + Organization = 'example-org' + } + @{ + Name = 'self-hosted SSH with a non-web port' + Repository = 'ssh://git@git.example.com:2222/platform/module.git' + ProjectUri = 'https://git.example.com/platform/module' + Organization = 'platform' + } + @{ + Name = 'self-hosted HTTP' + Repository = 'http://git.example.net/platform/module.git' + ProjectUri = 'http://git.example.net/platform/module' + Organization = 'platform' + } + ) { + $result = Resolve-PSModuleGitRemote -RepositoryUrl $Repository + + $result.ProjectUri | Should -Be $ProjectUri + $result.Organization | Should -Be $Organization + } + + It 'should reject an unsupported local-path remote' { + { + Resolve-PSModuleGitRemote -RepositoryUrl 'C:/repos/module' + } | Should -Throw '*Unsupported Git remote URL format*' + } +} diff --git a/src/public/Test-PSModule.Tests.ps1 b/tests/Test-PSModule.Tests.ps1 similarity index 71% rename from src/public/Test-PSModule.Tests.ps1 rename to tests/Test-PSModule.Tests.ps1 index 06f0723..a32e234 100644 --- a/src/public/Test-PSModule.Tests.ps1 +++ b/tests/Test-PSModule.Tests.ps1 @@ -4,7 +4,7 @@ param () Describe 'Unit Tests' -Tag 'Unit' { BeforeAll { - . $PSScriptRoot/Test-PSModule.ps1 + . $PSScriptRoot/../src/Public/Test-PSModule.ps1 } Context 'when no test files are found' { @@ -14,7 +14,7 @@ Describe 'Unit Tests' -Tag 'Unit' { Mock Invoke-Pester {} Test-PSModule ` -Name 'TestModule' ` - -SourceDirectory $emptyDir ` + -TestPath $emptyDir ` -WarningVariable warnings ` -WarningAction SilentlyContinue ( $warnings -join ' ' ) | Should -Match 'No test files found' @@ -31,15 +31,21 @@ Describe 'Unit Tests' -Tag 'Unit' { Mock Invoke-Pester {} } - It 'should invoke Pester with the source directory in the configuration' { - Test-PSModule -Name 'TestModule' -SourceDirectory $script:testDir + It 'should invoke Pester with the test path in the configuration' { + Test-PSModule -Name 'TestModule' -TestPath $script:testDir Should -Invoke Invoke-Pester -Times 1 -Exactly -ParameterFilter { $Configuration.Run.Path.Value -contains $script:testDir } } + It 'should throw on failed tests without exiting the caller host' { + Test-PSModule -Name 'TestModule' -TestPath $script:testDir + Should -Invoke Invoke-Pester -Times 1 -Exactly -ParameterFilter { + $Configuration.Run.Throw.Value -and -not $Configuration.Run.Exit.Value + } + } It 'should set a Tag filter when -Tag is provided' { - Test-PSModule -Name 'TestModule' -SourceDirectory $script:testDir -Tag 'Unit' + Test-PSModule -Name 'TestModule' -TestPath $script:testDir -Tag 'Unit' Should -Invoke Invoke-Pester -Times 1 -Exactly -ParameterFilter { $Configuration.Filter.Tag.Value -contains 'Unit' }