diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..df492b0 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# The docs are generated by the build script and should be considered artifacts +docs/en-US/* linguist-generated diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..116311a --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,20 @@ +{ + "$schema": "https://raw.githubusercontent.com/DavidAnson/markdownlint-cli2/v0.19.1/schema/markdownlint-cli2-config-schema.json", + "config": { + "MD013": { + "tables": false, + "code_blocks": false + }, + "MD024": { + "siblings_only": true + } + }, + "ignores": [ + "AGENTS.md", + // Intentionally narrow: only ignores docs/en-US (platyPS-generated help) so that + // other markdown files in docs/ are still linted. No other language directories + // are expected. + "docs/en-US/**", + "instructions/**" + ] +} diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 3eeb9c8..d3b2a1c 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -241,8 +241,8 @@ if (Test-Path -Path $templateModuleFolder) { # Rename example function files $publicFolder = Join-Path -Path $moduleFolder -ChildPath 'Public' $privateFolder = Join-Path -Path $moduleFolder -ChildPath 'Private' - $testPublicFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests\Unit\Public' - $testPrivateFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests\Unit\Private' + $testPublicFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Public' + $testPrivateFolder = Join-Path -Path $PSScriptRoot -ChildPath 'tests/Unit/Private' $foldersToCheck = @($publicFolder, $privateFolder, $testPublicFolder, $testPrivateFolder) @@ -262,6 +262,31 @@ if (Test-Path -Path $templateModuleFolder) { Write-Host ' Renamed example function files' -ForegroundColor Green } +# Rename files in docs/en-US/ that contain {{ModuleName}} placeholder (e.g., about_{{ModuleName}}.help.md) +$docsFolder = Join-Path -Path $PSScriptRoot -ChildPath 'docs/en-US' +if (Test-Path -Path $docsFolder) { + $docsFiles = Get-ChildItem -Path $docsFolder -File | Where-Object { + $_.Name -match '\{\{ModuleName\}\}' + } + foreach ($file in $docsFiles) { + $newName = $file.Name -replace '\{\{ModuleName\}\}', $ModuleName + Rename-Item -Path $file.FullName -NewName $newName + Write-Verbose "Renamed: $($file.Name) -> $newName" + } + if ($docsFiles) { + Write-Host " Renamed docs/en-US files" -ForegroundColor Green + } +} + +# Replace template-facing README.md with the module-facing README.template.md +# (placeholders inside README.template.md were already substituted by the file-processing loop above) +$readmeTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'README.template.md' +$readmePath = Join-Path -Path $PSScriptRoot -ChildPath 'README.md' +if (Test-Path -Path $readmeTemplate) { + Move-Item -Path $readmeTemplate -Destination $readmePath -Force + Write-Host ' Generated module README.md from template' -ForegroundColor Green +} + # Initialize Git repository if requested if (-not $NoGitInit) { $gitFolder = Join-Path -Path $PSScriptRoot -ChildPath '.git' @@ -303,7 +328,7 @@ Write-Host '========================================' -ForegroundColor Green Write-Host '' Write-Host 'Next steps:' -ForegroundColor Cyan Write-Host " 1. Review the generated files in the $ModuleName folder" -Write-Host ' 2. Update the README.md with your project details' +Write-Host ' 2. Review README.md and adjust to taste' Write-Host ' 3. Add your functions to the Public/ and Private/ folders' Write-Host ' 4. Run ./build.ps1 -Task Test to verify everything works' Write-Host ' 5. Push to your GitHub repository' diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index e3487c2..a521c0e 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,3 +1,28 @@ +# https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer @{ - IncludeRules = @('*') + IncludeDefaultRules = $true + + IncludeRules = @( + # Default rules + 'PS*' + ) + + # If IncludeRules and ExcludeRules are empty, all rules will be applied + ExcludeRules = @() + + Rules = @{ + # PSUseCompatibleSyntax = @{ + # # This turns the rule on (setting it to false will turn it off) + # Enable = $true + + # # List the targeted versions of PowerShell here + # TargetVersions = @( + # '5.1', + # '7.2' + # ) + # } + # PSUseCompatibleCmdlets = @{ + # compatibility = @('core-7.2.0-windows') + # } + } } diff --git a/README.md b/README.md index bf154ca..6d1df4a 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,122 @@ -# {{ModuleName}} - -{{Description}} - -## Installation - -### From PowerShell Gallery - -```powershell -Install-Module -Name {{ModuleName}} -Scope CurrentUser -``` - -### From Source - -```powershell -git clone {{ProjectUri}}.git -cd {{ModuleName}} -./build.ps1 -Task Build -Bootstrap -Import-Module ./Output/{{ModuleName}}/*/{{ModuleName}}.psd1 +# PowerShell Module Template + +A GitHub repository template for building, testing, and publishing PowerShell modules. Click **Use this template** at the top of the repo, run a one-shot init script, and you have a working module project with CI, tests, and documentation scaffolding ready to go. + +## What's included + +### Build & test + +- **psake + PowerShellBuild** task pipeline (`build.ps1`, `build.psake.ps1`) +- **Pester 5.x** test layout with `tests/Unit/{Public,Private}` scaffolding +- **PSScriptAnalyzer** lint configuration +- **Code coverage** via JaCoCo + Codecov (`codecov.yml`) +- **Manifest validation tests** with SemVer-aware version-constraint checks (`tests/Manifest.tests.ps1`, `tests/ManifestHelpers.psm1`) +- **Help documentation tests** that verify every public function has comment-based help with synopsis, description, and examples (`tests/Help.tests.ps1`) +- **Meta tests** that catch UTF-16 files and tab indentation (`tests/Meta.tests.ps1`) +- **Integration test loader** — `tests/local.settings.example.ps1` documents how to wire local secrets without committing them + +### CI/CD (GitHub Actions) + +- `CI.yaml` — lint + test on push and PR +- `PublishModuleToPowerShellGallery.yaml` — publish on release +- `auto-merge-bots.yml` — auto-merge dependabot/pre-commit PRs +- `ggshield.yaml` — secret scanning +- Dependabot config and FUNDING file + +### Developer experience + +- **`.devcontainer/`** with Docker Compose + host setup script +- **`.pre-commit-config.yaml`** with ggshield secret scanning +- **`instructions/`** — 12 markdown guides for AI agents (PowerShell style, testing, releases, git workflow, etc.) +- **`AGENTS.md`** — top-level AI agent guidance +- Markdown linting via `.markdownlint-cli2.jsonc` + +### Module scaffolding + +- Full `.psd1` manifest with PSEdition tags, license/project URIs, and PSData metadata +- `.psm1` with public/private dot-source pattern +- Example public function (`Get-{{Prefix}}Example`) and private helper (`Invoke-{{Prefix}}Helper`) +- `docs/en-US/about_{{ModuleName}}.help.md` stub for `Get-Help about_` + +## Quick start + +1. Click **Use this template → Create a new repository** at the top of this repo. +2. Clone your new repository locally. +3. Run the initialization script: + + ```powershell + ./Initialize-Template.ps1 + ``` + + You'll be prompted for module name, function prefix, author, description, and project URL. Pass them as parameters for non-interactive use: + + ```powershell + ./Initialize-Template.ps1 ` + -ModuleName 'MyAwesomeModule' ` + -Prefix 'Mam' ` + -Author 'Jane Doe' ` + -Description 'Does awesome things' ` + -ProjectUri 'https://github.com/janedoe/MyAwesomeModule' + ``` + +4. The script substitutes placeholders, renames files, optionally runs `git init`, and bootstraps build dependencies. Delete `Initialize-Template.ps1` when done. + +## Placeholders + +`Initialize-Template.ps1` replaces these tokens across all `.ps1`, `.psm1`, `.psd1`, `.md`, `.json`, `.yml`, `.yaml`, `.xml`, and `.txt` files: + +| Placeholder | Replaced with | Example | +|---|---|---| +| `{{ModuleName}}` | Module name | `MyAwesomeModule` | +| `{{Prefix}}` | Function noun prefix | `Mam` | +| `{{Author}}` | Author name | `Jane Doe` | +| `{{Description}}` | Module description | `Does awesome things` | +| `{{ProjectUri}}` | Repository URL | `https://github.com/...` | +| `{{GUID}}` | Generated GUID | (new GUID per run) | +| `{{Date}}` | ISO date at init | `2026-04-29` | +| `{{Year}}` | Year at init | `2026` | + +The script also renames the `{{ModuleName}}` folder, files containing `{{ModuleName}}` or `{{Prefix}}` in their names (in `Public/`, `Private/`, `tests/Unit/`, `docs/en-US/`), and replaces `README.md` with the post-init module README sourced from `README.template.md`. + +## Project structure (post-init) + +```text +/ +├── / # Module source +│ ├── Public/ # Exported functions +│ └── Private/ # Internal helpers +├── tests/ +│ ├── Unit/{Public,Private}/ # Per-function tests +│ ├── Help.tests.ps1 # Comment-based-help validation +│ ├── Manifest.tests.ps1 # Manifest + dependency-version validation +│ ├── Meta.tests.ps1 # Encoding + indentation checks +│ └── ManifestHelpers.psm1 # SemVer comparison helpers +├── docs/en-US/ # platyPS help (generated) +├── instructions/ # AI agent guidance (12 files) +├── .github/workflows/ # CI, publish, auto-merge, secret scan +├── .devcontainer/ # VS Code dev container +├── build.ps1 # Build entry point +├── build.psake.ps1 # psake task definitions +├── build.depend.psd1 # Build/test module dependencies +└── requirements.psd1 # Runtime module dependencies ``` -## Requirements - -- PowerShell 5.1 or later (Desktop or Core) -- Windows, Linux, or macOS +## Working on the template itself -## Quick Start +If you want to contribute to the template (this repo) rather than use it: ```powershell -# Import the module -Import-Module {{ModuleName}} - -# Get help for available commands -Get-Command -Module {{ModuleName}} - -# Example usage -Get-{{Prefix}}Example -Name 'World' +./build.ps1 -Task Test -Bootstrap ``` -## Available Commands - -| Command | Description | -|---------|-------------| -| `Get-{{Prefix}}Example` | Example public function | +The test suite runs against the `{{ModuleName}}` placeholder module to verify the scaffolding is sound. See [AGENTS.md](AGENTS.md) and [`instructions/`](instructions/) for contribution conventions. -## Development - -### Prerequisites +## Requirements - PowerShell 5.1+ or PowerShell 7+ - Git - -### Building - -```powershell -# Clone the repository -git clone {{ProjectUri}}.git -cd {{ModuleName}} - -# Bootstrap dependencies and build -./build.ps1 -Task Build -Bootstrap - -# Run tests -./build.ps1 -Task Test -``` - -### Project Structure - -``` -{{ModuleName}}/ -├── {{ModuleName}}/ # Module source -│ ├── Public/ # Exported functions -│ └── Private/ # Internal helpers -├── tests/ # Pester tests -│ ├── Unit/ # Unit tests -│ ├── Meta.tests.ps1 # Code style tests -│ ├── Manifest.tests.ps1 # Manifest validation -│ └── Help.tests.ps1 # Help documentation tests -├── docs/ # Documentation -├── .github/workflows/ # CI/CD pipelines -└── build.ps1 # Build entry point -``` - -### Available Build Tasks - -```powershell -./build.ps1 -Help -``` - -| Task | Description | -|------|-------------| -| `Build` | Build the module to Output/ | -| `Test` | Run all tests with code coverage | -| `Analyze` | Run PSScriptAnalyzer | -| `Pester` | Run Pester tests only | -| `Clean` | Remove build artifacts | -| `Publish` | Publish to PowerShell Gallery | - -## Contributing - -1. Fork the repository -2. Create a feature branch -3. Make your changes -4. Run tests: `./build.ps1 -Task Test` -5. Submit a pull request +- (Optional) Docker for the devcontainer ## License -This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. - -## Changelog - -See [CHANGELOG.md](CHANGELOG.md) for version history. +[MIT](LICENSE) diff --git a/README.template.md b/README.template.md new file mode 100644 index 0000000..49a4628 --- /dev/null +++ b/README.template.md @@ -0,0 +1,113 @@ +# {{ModuleName}} + +{{Description}} + +## Installation + +### From PowerShell Gallery + +```powershell +Install-Module -Name {{ModuleName}} -Scope CurrentUser +``` + +### From Source + +```powershell +git clone {{ProjectUri}}.git +cd {{ModuleName}} +./build.ps1 -Task Build -Bootstrap +Import-Module ./Output/{{ModuleName}}/*/{{ModuleName}}.psd1 +``` + +## Requirements + +- PowerShell 5.1 or later (Desktop or Core) +- Windows, Linux, or macOS + +## Quick Start + +```powershell +# Import the module +Import-Module {{ModuleName}} + +# Get help for available commands +Get-Command -Module {{ModuleName}} + +# Example usage +Get-{{Prefix}}Example -Name 'World' +``` + +## Available Commands + +| Command | Description | +|---------|-------------| +| `Get-{{Prefix}}Example` | Example public function | + +## Development + +### Prerequisites + +- PowerShell 5.1+ or PowerShell 7+ +- Git + +### Building + +```powershell +# Clone the repository +git clone {{ProjectUri}}.git +cd {{ModuleName}} + +# Bootstrap dependencies and build +./build.ps1 -Task Build -Bootstrap + +# Run tests +./build.ps1 -Task Test +``` + +### Project Structure + +```text +{{ModuleName}}/ +├── {{ModuleName}}/ # Module source +│ ├── Public/ # Exported functions +│ └── Private/ # Internal helpers +├── tests/ # Pester tests +│ ├── Unit/ # Unit tests +│ ├── Meta.tests.ps1 # Code style tests +│ ├── Manifest.tests.ps1 # Manifest validation +│ └── Help.tests.ps1 # Help documentation tests +├── docs/ # Documentation +├── .github/workflows/ # CI/CD pipelines +└── build.ps1 # Build entry point +``` + +### Available Build Tasks + +```powershell +./build.ps1 -Help +``` + +| Task | Description | +|------|-------------| +| `Build` | Build the module to Output/ | +| `Test` | Run all tests with code coverage | +| `Analyze` | Run PSScriptAnalyzer | +| `Pester` | Run Pester tests only | +| `Clean` | Remove build artifacts | +| `Publish` | Publish to PowerShell Gallery | + +## Contributing + +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Run tests: `./build.ps1 -Task Test` +5. Submit a pull request + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. + +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for version history. diff --git a/build.depend.psd1 b/build.depend.psd1 index e8900de..22be15d 100644 --- a/build.depend.psd1 +++ b/build.depend.psd1 @@ -23,6 +23,6 @@ Version = '0.7.3' } 'PSScriptAnalyzer' = @{ - Version = '1.24.0' + Version = '1.25.0' } } diff --git a/docs/en-US/about_{{ModuleName}}.help.md b/docs/en-US/about_{{ModuleName}}.help.md new file mode 100644 index 0000000..55c7a03 --- /dev/null +++ b/docs/en-US/about_{{ModuleName}}.help.md @@ -0,0 +1,17 @@ +# {{ModuleName}} + +## about_{{ModuleName}} + +## SHORT DESCRIPTION + +## LONG DESCRIPTION + +## EXAMPLES + +## NOTE + +## TROUBLESHOOTING NOTE + +## SEE ALSO + +## KEYWORDS diff --git a/tests/Help.tests.ps1 b/tests/Help.tests.ps1 index 8ad1d69..4f1a16c 100644 --- a/tests/Help.tests.ps1 +++ b/tests/Help.tests.ps1 @@ -213,7 +213,7 @@ Describe "Test help for <_.Name>" -ForEach $commands { # Shouldn't find extra parameters in help It 'finds help parameter in code: <_>' { - $_ -in $parameterNames | Should -Be $true + $_ -in $commandParameterNames | Should -Be $true } } } diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 83a1602..1f33276 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -24,6 +24,36 @@ 'dependencies', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyName', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'dependencyRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'manifestRawData', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersionSkipReason', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'requirementsVersion', + Justification = 'false positive' +)] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'candidateVersion', + Justification = 'false positive' +)] param() BeforeDiscovery { @@ -85,7 +115,19 @@ BeforeAll { ErrorAction = 'Stop' WarningAction = 'SilentlyContinue' } + $importDataFileParameters = @{ + Path = $moduleManifestPath + ErrorAction = 'Stop' + WarningAction = 'SilentlyContinue' + } $manifestData = Test-ModuleManifest @testModuleManifestParameters + $manifestRawData = Import-PowerShellDataFile @importDataFileParameters + + # Import ManifestHelpers.psm1 for SemVer helper functions + Import-Module -Name (Join-Path -Path $PSScriptRoot -ChildPath 'ManifestHelpers.psm1') -Verbose:$false -Force + + $requirementsPath = Join-Path -Path $env:BHProjectPath -ChildPath 'requirements.psd1' + $requirements = Import-PowerShellDataFile -Path $requirementsPath -ErrorAction Stop # Parse the version from the changelog $changelogPath = Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.md' @@ -143,19 +185,118 @@ Describe 'Module manifest' { } Context 'Module Dependency' -ForEach $dependencies { - # This ensures we keep our dependant modules in sync between the manifest file and the requirements + # This ensures we keep our dependent modules in sync between the manifest file and the requirements # script used to bootstrap and test. BeforeAll { - $requirementsPath = Join-Path -Path $Env:BHProjectPath -Child 'requirements.psd1' - $requirements = Import-PowerShellDataFile $requirementsPath + $dependencyName = $_.Name + $dependencyRawData = $manifestRawData.RequiredModules | Where-Object { + $_ -eq $dependencyName -or $_.ModuleName -eq $dependencyName + } + # Ensure exactly one match - duplicates should fail, not silently skip + if (@($dependencyRawData).Count -gt 1) { + throw "Duplicate RequiredModules entry found for '$dependencyName'" + } + # Handle plain-string module references (not hashtables with version info) + if ($dependencyRawData -isnot [hashtable]) { + $dependencyRawData = $null + } + + # Extract version from requirements.psd1 (shared logic for all version constraint tests) + $requirementsVersionSkipReason = $null + $requirementsVersion = $null + + if (-not $requirements.ContainsKey($dependencyName)) { + $requirementsVersionSkipReason = 'dependency not found in requirements.psd1' + } elseif ($requirements.Item($dependencyName) -is [string]) { + # Plain string format: 'ModuleName' = '1.2.3' + $candidateVersion = $requirements.Item($dependencyName) + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } + } elseif ($requirements.Item($dependencyName) -is [hashtable] -and $requirements.Item($dependencyName).ContainsKey('Version')) { + # Hashtable format: 'ModuleName' = @{ Version = '1.2.3' } + $candidateVersion = $requirements.Item($dependencyName).Version + if ([string]::IsNullOrWhiteSpace($candidateVersion)) { + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' has an empty Version" + } else { + $requirementsVersion = $candidateVersion + } + } else { + # Invalid format + $requirementsVersionSkipReason = "requirements.psd1 entry for '$dependencyName' must be a string or hashtable with a Version key" + } + } + + It '<_.Name> exists in requirements.psd1' { + $requirements.ContainsKey($dependencyName) | Should -BeTrue } - It '<_.Name> exists in Requirements.psd1' { - $requirements.ContainsKey($_.Name) | Should -BeTrue + It '<_.Name> uses at least one version key' { + if ($null -eq $dependencyRawData) { + Set-ItResult -Skipped -Because 'Plain-string module reference without version constraints' + } + + # Valid dependency version keys + $validDependencyKeys = @( + 'ModuleVersion' # Specifies a minimum acceptable version of the module + 'RequiredVersion' # Specifies an exact, required version of the module + 'MaximumVersion' # Specifies a maximum acceptable version of the module + ) + $dependencyKeysUsed = $dependencyRawData.Keys | Where-Object { $_ -in $validDependencyKeys } + $dependencyKeysUsed.Count | Should -BeGreaterThan 0 + } + + It '<_.Name> has a matching required version in requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('RequiredVersion')) { + Set-ItResult -Skipped -Because 'No RequiredVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.RequiredVersion + RequirementsVersion = $requirementsVersion + Constraint = 'Equal' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } - It '<_.Name> has matching version in the Requirements.psd1' { - [Version]$requirements.Item($_.Name).Version | Should -Be $_.Version + It '<_.Name> has a maximum version greater than or equal to requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('MaximumVersion')) { + Set-ItResult -Skipped -Because 'No MaximumVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.MaximumVersion + RequirementsVersion = $requirementsVersion + Constraint = 'GreaterOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue + } + + It '<_.Name> has a minimum version at or below requirements.psd1' { + if ($null -eq $dependencyRawData -or -not $dependencyRawData.ContainsKey('ModuleVersion')) { + Set-ItResult -Skipped -Because 'No ModuleVersion specified in the manifest' + } + + if ($requirementsVersionSkipReason) { + Set-ItResult -Skipped -Because $requirementsVersionSkipReason + } + + $constraintParameters = @{ + ManifestVersion = $dependencyRawData.ModuleVersion + RequirementsVersion = $requirementsVersion + Constraint = 'LessOrEqual' + } + Test-VersionConstraint @constraintParameters | Should -BeTrue } } } diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 new file mode 100644 index 0000000..5ac5d25 --- /dev/null +++ b/tests/ManifestHelpers.psm1 @@ -0,0 +1,383 @@ +<# + This module provides helper functions for validating module dependency versions + using Semantic Versioning (SemVer) conventions, including prerelease version support. +#> + +function Split-SemVerString { + <# + .SYNOPSIS + Splits a version string into version and prerelease components. + + .DESCRIPTION + Parses a SemVer-formatted version string (e.g., "1.2.3-beta.1") into separate + version and prerelease components. This enables proper comparison of prerelease + versions using SemVer 2.0.0 specification rules. + + .PARAMETER VersionString + The version string to parse. Can be in the format "1.2.3" or "1.2.3-prerelease". + + .OUTPUTS + [hashtable] + Returns a hashtable with two keys: + - Version: The numeric version portion (e.g., "1.2.3") + - Prerelease: The prerelease identifier (e.g., "beta.1") or $null if none + + .EXAMPLE + Split-SemVerString -VersionString "1.2.3-beta.1" + + Returns: @{ Version = "1.2.3"; Prerelease = "beta.1" } + + .EXAMPLE + Split-SemVerString -VersionString "2.0.0" + + Returns: @{ Version = "2.0.0"; Prerelease = $null } + #> + [CmdletBinding()] + [OutputType([hashtable])] + param( + [Parameter(Mandatory = $true)] + [string]$VersionString + ) + + if ([string]::IsNullOrEmpty($VersionString)) { + throw "VersionString cannot be empty or null" + } + + # Strip build metadata per SemVer 2.0.0 — it does not affect precedence and is + # not valid for [System.Version], so it must be removed before further parsing. + $coreVersion = ($VersionString -split '\+', 2)[0] + $parts = $coreVersion -split '-', 2 + return @{ + Version = $parts[0] + Prerelease = if ($parts.Length -gt 1) { $parts[1] } else { $null } + } +} + +function Compare-SemVerPrerelease { + <# + .SYNOPSIS + Compares two SemVer prerelease identifiers according to SemVer 2.0.0 specification. + + .DESCRIPTION + Implements SemVer 2.0.0 prerelease comparison rules (section 11.4): + - Compares dot-separated identifiers from left to right + - Numeric identifiers are compared as integers + - Alphanumeric identifiers are compared lexically (ASCII sort) + - Numeric identifiers always have lower precedence than alphanumeric + - More identifiers > fewer identifiers (if all preceding are equal) + + .PARAMETER FirstPrerelease + The first prerelease identifier (e.g., "alpha.1"). Must not be null. + + .PARAMETER SecondPrerelease + The second prerelease identifier (e.g., "beta.2"). Must not be null. + + .OUTPUTS + [int] + Returns -1 if first < second, 0 if equal, 1 if first > second. + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "alpha.1" -SecondPrerelease "alpha.2" + Returns: -1 (alpha.1 < alpha.2) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "beta.11" -SecondPrerelease "beta.2" + Returns: 1 (11 > 2 numerically) + + .EXAMPLE + Compare-SemVerPrerelease -FirstPrerelease "1" -SecondPrerelease "alpha" + Returns: -1 (numeric < alphanumeric) + #> + [CmdletBinding()] + [OutputType([int])] + param( + [Parameter(Mandatory = $true)] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [string]$SecondPrerelease + ) + + $firstIdentifiers = $FirstPrerelease -split '\.' + $secondIdentifiers = $SecondPrerelease -split '\.' + + $maxLength = [Math]::Max($firstIdentifiers.Length, $secondIdentifiers.Length) + + for ($i = 0; $i -lt $maxLength; $i++) { + # If first has fewer identifiers, it has lower precedence + if ($i -ge $firstIdentifiers.Length) { + return -1 + } + + # If second has fewer identifiers, it has lower precedence + if ($i -ge $secondIdentifiers.Length) { + return 1 + } + + $firstId = $firstIdentifiers[$i] + $secondId = $secondIdentifiers[$i] + + # Check if identifiers are numeric (consist only of digits) + $firstIsNumeric = $firstId -match '^\d+$' + $secondIsNumeric = $secondId -match '^\d+$' + + if ($firstIsNumeric -and $secondIsNumeric) { + # SemVer 2.0.0 permits arbitrarily large numeric identifiers — use BigInteger + # rather than [long] to avoid overflow. + $firstNum = [bigint]$firstId + $secondNum = [bigint]$secondId + + if ($firstNum -lt $secondNum) { + return -1 + } + elseif ($firstNum -gt $secondNum) { + return 1 + } + # Equal, continue to next identifier + } + elseif ($firstIsNumeric) { + # First is numeric, second is alphanumeric: numeric < alphanumeric + return -1 + } + elseif ($secondIsNumeric) { + # First is alphanumeric, second is numeric: alphanumeric > numeric + return 1 + } + else { + # Both alphanumeric: compare lexically + $comparison = [string]::Compare($firstId, $secondId, [System.StringComparison]::Ordinal) + if ($comparison -ne 0) { + return [Math]::Sign($comparison) + } + # Equal, continue to next identifier + } + } + + # All identifiers are equal + return 0 +} + +function Test-VersionComparison { + <# + .SYNOPSIS + Compares two SemVer versions according to SemVer 2.0.0 specification. + + .DESCRIPTION + Compares two semantic versions, including their prerelease components, following + SemVer 2.0.0 rules. Returns $true if the first version is newer than the second version. + + Comparison logic: + 1. Compare base versions (major.minor.patch) numerically + 2. If base versions equal, apply prerelease precedence rules: + - Version without prerelease > version with prerelease + - Compare prerelease identifiers using SemVer rules + + .PARAMETER FirstVersion + The numeric version portion of the first version (e.g., "1.2.3"). + + .PARAMETER FirstPrerelease + The prerelease identifier of the first version (e.g., "beta.1") or $null if none. + + .PARAMETER SecondVersion + The numeric version portion of the second version (e.g., "1.2.2"). + + .PARAMETER SecondPrerelease + The prerelease identifier of the second version (e.g., "alpha.5") or $null if none. + + .OUTPUTS + [bool] + Returns $true if the first version is newer than the second version, $false otherwise. + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease "beta.1" -SecondVersion "1.2.3" -SecondPrerelease "alpha.1" + + Returns: $true (beta.1 is newer than alpha.1) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.2" -SecondPrerelease $null + + Returns: $true (1.2.3 is newer than 1.2.2) + + .EXAMPLE + Test-VersionComparison -FirstVersion "1.2.3" -FirstPrerelease $null -SecondVersion "1.2.3" -SecondPrerelease "beta.1" + + Returns: $true (1.2.3 > 1.2.3-beta.1 per SemVer spec) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$FirstVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$FirstPrerelease, + + [Parameter(Mandatory = $true)] + [string]$SecondVersion, + + [Parameter(Mandatory = $false)] + [AllowNull()] + [string]$SecondPrerelease + ) + + # Normalize versions to ensure consistent component count (fixes .NET Version comparison quirks) + # .NET treats "1.0.0" (Revision=-1) and "1.0.0.0" (Revision=0) as different, so normalize both to 4 components + $normalizedFirst = $FirstVersion + $firstComponents = $normalizedFirst.Split('.') + if ($firstComponents.Count -gt 4) { + throw "Version string '$FirstVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $firstComponents.Count; $c -lt 4; $c++) { $normalizedFirst += '.0' } + + $normalizedSecond = $SecondVersion + $secondComponents = $normalizedSecond.Split('.') + if ($secondComponents.Count -gt 4) { + throw "Version string '$SecondVersion' has too many components. .NET Version supports maximum 4 components (Major.Minor.Build.Revision)." + } + for ($c = $secondComponents.Count; $c -lt 4; $c++) { $normalizedSecond += '.0' } + + # Compare base versions using .NET Version type + $firstVer = [version]$normalizedFirst + $secondVer = [version]$normalizedSecond + + $versionComparison = $firstVer.CompareTo($secondVer) + + if ($versionComparison -ne 0) { + # Base versions differ: return based on numeric comparison + return $versionComparison -gt 0 + } + + # Base versions are equal, apply prerelease precedence rules + $firstHasPrerelease = -not [string]::IsNullOrEmpty($FirstPrerelease) + $secondHasPrerelease = -not [string]::IsNullOrEmpty($SecondPrerelease) + + if (-not $firstHasPrerelease -and -not $secondHasPrerelease) { + # Both are release versions: equal + return $false + } + + if (-not $firstHasPrerelease) { + # First is release, second is prerelease: release > prerelease + return $true + } + + if (-not $secondHasPrerelease) { + # First is prerelease, second is release: prerelease < release + return $false + } + + # Both have prerelease: compare using SemVer prerelease rules + $prereleaseComparison = Compare-SemVerPrerelease -FirstPrerelease $FirstPrerelease -SecondPrerelease $SecondPrerelease + return $prereleaseComparison -gt 0 +} + +function Test-VersionConstraint { + <# + .SYNOPSIS + Tests whether a manifest version satisfies a version constraint. + + .DESCRIPTION + Validates that a module manifest version meets a specific constraint relative to + a requirements version. Supports three constraint types: + - Equal: Versions must be exactly the same (for RequiredVersion) + - GreaterOrEqual: Manifest version must be >= requirements (for MaximumVersion) + - LessOrEqual: Manifest version must be <= requirements (for ModuleVersion/minimum) + + .PARAMETER ManifestVersion + The version from the module manifest (e.g., "1.2.3-beta.1"). + + .PARAMETER RequirementsVersion + The version from requirements.psd1 (e.g., "1.2.3"). + + .PARAMETER Constraint + The type of constraint to validate. Valid values are: + - Equal: Versions must match exactly + - GreaterOrEqual: ManifestVersion >= RequirementsVersion + - LessOrEqual: ManifestVersion <= RequirementsVersion + + .OUTPUTS + [bool] + Returns $true if the constraint is satisfied, $false otherwise. + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3" -RequirementsVersion "1.2.3" -Constraint "Equal" + + Returns: $true (versions match exactly) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "2.0.0" -RequirementsVersion "1.5.0" -Constraint "GreaterOrEqual" + + Returns: $true (2.0.0 >= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.0.0" -RequirementsVersion "1.5.0" -Constraint "LessOrEqual" + + Returns: $true (1.0.0 <= 1.5.0) + + .EXAMPLE + Test-VersionConstraint -ManifestVersion "1.2.3-beta.1" -RequirementsVersion "1.2.3-alpha.5" -Constraint "GreaterOrEqual" + + Returns: $true (beta.1 is newer than alpha.5) + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [Parameter(Mandatory = $true)] + [string]$ManifestVersion, + + [Parameter(Mandatory = $true)] + [string]$RequirementsVersion, + + [Parameter(Mandatory = $true)] + [ValidateSet('Equal', 'GreaterOrEqual', 'LessOrEqual')] + [string]$Constraint + ) + + # Validate input versions are not empty + if ([string]::IsNullOrWhiteSpace($ManifestVersion)) { + throw "ManifestVersion cannot be empty or whitespace" + } + if ([string]::IsNullOrWhiteSpace($RequirementsVersion)) { + throw "RequirementsVersion cannot be empty or whitespace" + } + + $manifestParts = Split-SemVerString $ManifestVersion + $requirementsParts = Split-SemVerString $RequirementsVersion + + $comparisonParameters = @{ + FirstVersion = $requirementsParts.Version + FirstPrerelease = $requirementsParts.Prerelease + SecondVersion = $manifestParts.Version + SecondPrerelease = $manifestParts.Prerelease + } + $requirementsIsNewer = Test-VersionComparison @comparisonParameters + + $reversedComparisonParameters = @{ + FirstVersion = $manifestParts.Version + FirstPrerelease = $manifestParts.Prerelease + SecondVersion = $requirementsParts.Version + SecondPrerelease = $requirementsParts.Prerelease + } + $manifestIsNewer = Test-VersionComparison @reversedComparisonParameters + + switch ($Constraint) { + 'Equal' { + # RequiredVersion must exactly match (neither version is newer than the other) + return (-not $requirementsIsNewer) -and (-not $manifestIsNewer) + } + 'GreaterOrEqual' { + # MaximumVersion must be >= requirements (requirements not newer, or equal) + return (-not $requirementsIsNewer) + } + 'LessOrEqual' { + # ModuleVersion must be <= requirements (manifest not newer than requirements) + return (-not $manifestIsNewer) + } + default { + throw "Unsupported constraint: '$Constraint'" + } + } +} + +Export-ModuleMember -Function 'Test-VersionConstraint' diff --git a/{{ModuleName}}/{{ModuleName}}.psm1 b/{{ModuleName}}/{{ModuleName}}.psm1 index 954f7a1..ec2cf09 100644 --- a/{{ModuleName}}/{{ModuleName}}.psm1 +++ b/{{ModuleName}}/{{ModuleName}}.psm1 @@ -5,7 +5,8 @@ foreach ($import in @($public + $private)) { try { . $import.FullName } catch { - throw "Unable to dot source [$($import.FullName)]" + Write-Error "Unable to dot source '$($import.FullName)'" + throw } }