From 73810c53e7e69bcedd284057146bd5a004014df0 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:03:39 -0400 Subject: [PATCH 01/10] fix(tests): correct $parameterNames variable and preserve original exception - tests/Help.tests.ps1: rename undefined $parameterNames to $commandParameterNames in the help-vs-code parameter existence check. The variable was never assigned in scope, silently making the assertion always evaluate against $null. - {{ModuleName}}/{{ModuleName}}.psm1: re-throw original exception object in the dot-source catch block instead of throwing a new string, preserving stack traces and inner-exception details for debugging. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Help.tests.ps1 | 2 +- {{ModuleName}}/{{ModuleName}}.psm1 | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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/{{ModuleName}}/{{ModuleName}}.psm1 b/{{ModuleName}}/{{ModuleName}}.psm1 index 954f7a1..6c1fa95 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 $_ } } From 9b494253bac724b9fbd1a9732cac1129c88cc243 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:04:55 -0400 Subject: [PATCH 02/10] feat(tests): SemVer-aware dependency version constraints Replace the single-shape Manifest.tests.ps1 dependency check with constraint- specific assertions and a SemVer comparison helper module. - tests/ManifestHelpers.psm1: new module exporting Test-VersionConstraint, with SemVer 2.0.0 prerelease ordering, .NET Version normalization, and Equal / GreaterOrEqual / LessOrEqual constraint modes. - tests/Manifest.tests.ps1: differentiate ModuleVersion (minimum), RequiredVersion (exact), and MaximumVersion (maximum) checks; accept both string and hashtable shapes for entries in requirements.psd1; detect duplicate RequiredModules entries; skip with a reason for plain-string dependencies. Preserves the existing BHBuildOutput override that points at Output///. Validated end-to-end against an initialized SmokeTest module: 30 passed, 0 failed, 2 -Skip'd. Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/Manifest.tests.ps1 | 140 +++++++++++++- tests/ManifestHelpers.psm1 | 379 +++++++++++++++++++++++++++++++++++++ 2 files changed, 512 insertions(+), 7 deletions(-) create mode 100644 tests/ManifestHelpers.psm1 diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 83a1602..e52f6b8 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -24,6 +24,31 @@ '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' +)] param() BeforeDiscovery { @@ -85,7 +110,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 +180,108 @@ 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' + $requirementsVersion = $requirements.Item($dependencyName) + } elseif ($requirements.Item($dependencyName) -is [hashtable] -and $requirements.Item($dependencyName).ContainsKey('Version')) { + # Hashtable format: 'ModuleName' = @{ Version = '1.2.3' } + $requirementsVersion = $requirements.Item($dependencyName).Version + } 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> 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> exists in Requirements.psd1' { - $requirements.ContainsKey($_.Name) | Should -BeTrue + 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 matching version in the Requirements.psd1' { - [Version]$requirements.Item($_.Name).Version | Should -Be $_.Version + 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..21e694f --- /dev/null +++ b/tests/ManifestHelpers.psm1 @@ -0,0 +1,379 @@ +<# + 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" + } + + $parts = $VersionString -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) { + # Both numeric: compare as integers + $firstNum = [long]$firstId + $secondNum = [long]$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' From 91db22c6570412727e2ebff17b620474d9268ce5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:05 -0400 Subject: [PATCH 03/10] chore: add .gitattributes, markdownlint config, and about_ help stub - .gitattributes: mark docs/en-US/* as linguist-generated so platyPS-generated help files don't skew GitHub language stats. - .markdownlint-cli2.jsonc: relax MD013 in tables and code blocks, allow MD024 duplicate headings under different parents, and ignore AGENTS.md, the generated docs/en-US/**, and the instructions/** AI-agent guides. - docs/en-US/about_{{ModuleName}}.help.md: stub for Get-Help about_. Initialize-Template.ps1 already renames {{ModuleName}} files in docs/en-US. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitattributes | 2 ++ .markdownlint-cli2.jsonc | 20 ++++++++++++++++++++ docs/en-US/about_{{ModuleName}}.help.md | 19 +++++++++++++++++++ 3 files changed, 41 insertions(+) create mode 100644 .gitattributes create mode 100644 .markdownlint-cli2.jsonc create mode 100644 docs/en-US/about_{{ModuleName}}.help.md 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/docs/en-US/about_{{ModuleName}}.help.md b/docs/en-US/about_{{ModuleName}}.help.md new file mode 100644 index 0000000..f6b5d27 --- /dev/null +++ b/docs/en-US/about_{{ModuleName}}.help.md @@ -0,0 +1,19 @@ +# {{ModuleName}} + +## about_{{ModuleName}} + +## SHORT DESCRIPTION + +## LONG DESCRIPTION + +## EXAMPLES + +## NOTE + +## TROUBLESHOOTING NOTE + +## SEE ALSO + +## KEYWORDS + +- From 5bdf33b2e65f31c0b3ded387de8eb7c4036fa9c5 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:15 -0400 Subject: [PATCH 04/10] chore(analyzer): structured PSScriptAnalyzer settings, bump to 1.25.0 - PSScriptAnalyzerSettings.psd1: replace the single-line @{ IncludeRules = @('*') } with the standard structured form (IncludeDefaultRules + Include/Exclude/Rules blocks). Includes a commented-out PSUseCompatibleSyntax/PSUseCompatibleCmdlets scaffold for projects that want cross-version compatibility checks. - build.depend.psd1: bump PSScriptAnalyzer from 1.24.0 to 1.25.0 to pick up newer rule fixes. Co-Authored-By: Claude Opus 4.7 (1M context) --- PSScriptAnalyzerSettings.psd1 | 27 ++++++++++++++++++++++++++- build.depend.psd1 | 2 +- 2 files changed, 27 insertions(+), 2 deletions(-) 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/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' } } From 13bee8d4f4e2caef9969b8858bf1ddb83beffcf8 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 16:05:28 -0400 Subject: [PATCH 05/10] feat(init): split README and rename docs/en-US files during init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The repo's README.md was a placeholder-laden module README, which made it look like docs for a hypothetical {{ModuleName}} when viewed on GitHub. Split into two files so the GitHub template page sells the template itself. - README.md: rewritten as template documentation — what's included (build, CI, devcontainer, instructions, test infra), quick-start with Initialize- Template.ps1, placeholder reference table, and post-init project structure. - README.template.md: holds the original placeholder-based module README. The file-processing loop in Initialize-Template.ps1 still substitutes its placeholders, then the script moves it over README.md as the final step. - Initialize-Template.ps1: - Rename files in docs/en-US/ that contain {{ModuleName}} (e.g., about_{{ModuleName}}.help.md), parallel to the Public/Private/test Prefix-rename loop. - After renames, replace template-facing README.md with the now-substituted README.template.md. - Adjust the "Next steps" message to match the new flow. Co-Authored-By: Claude Opus 4.7 (1M context) --- Initialize-Template.ps1 | 27 ++++++- README.md | 167 +++++++++++++++++++++------------------- README.template.md | 113 +++++++++++++++++++++++++++ 3 files changed, 227 insertions(+), 80 deletions(-) create mode 100644 README.template.md diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 3eeb9c8..343bf29 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -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/README.md b/README.md index bf154ca..abcdb7f 100644 --- a/README.md +++ b/README.md @@ -1,113 +1,122 @@ -# {{ModuleName}} +# PowerShell Module Template -{{Description}} +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. -## Installation +## What's included -### From PowerShell Gallery +### Build & test -```powershell -Install-Module -Name {{ModuleName}} -Scope CurrentUser -``` +- **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 -### From Source +### CI/CD (GitHub Actions) -```powershell -git clone {{ProjectUri}}.git -cd {{ModuleName}} -./build.ps1 -Task Build -Bootstrap -Import-Module ./Output/{{ModuleName}}/*/{{ModuleName}}.psd1 -``` +- `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 -## Requirements +### Developer experience -- PowerShell 5.1 or later (Desktop or Core) -- Windows, Linux, or macOS +- **`.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` -## Quick Start +### Module scaffolding -```powershell -# Import the module -Import-Module {{ModuleName}} - -# Get help for available commands -Get-Command -Module {{ModuleName}} +- 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_` -# Example usage -Get-{{Prefix}}Example -Name 'World' -``` +## Quick start -## Available Commands +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: -| Command | Description | -|---------|-------------| -| `Get-{{Prefix}}Example` | Example public function | + ```powershell + ./Initialize-Template.ps1 + ``` -## Development + You'll be prompted for module name, function prefix, author, description, and project URL. Pass them as parameters for non-interactive use: -### Prerequisites + ```powershell + ./Initialize-Template.ps1 ` + -ModuleName 'MyAwesomeModule' ` + -Prefix 'Mam' ` + -Author 'Jane Doe' ` + -Description 'Does awesome things' ` + -ProjectUri 'https://github.com/janedoe/MyAwesomeModule' + ``` -- PowerShell 5.1+ or PowerShell 7+ -- Git +4. The script substitutes placeholders, renames files, optionally runs `git init`, and bootstraps build dependencies. Delete `Initialize-Template.ps1` when done. -### Building +## Placeholders -```powershell -# Clone the repository -git clone {{ProjectUri}}.git -cd {{ModuleName}} +`Initialize-Template.ps1` replaces these tokens across all `.ps1`, `.psm1`, `.psd1`, `.md`, `.json`, `.yml`, `.yaml`, `.xml`, and `.txt` files: -# Bootstrap dependencies and build -./build.ps1 -Task Build -Bootstrap +| 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` | -# Run tests -./build.ps1 -Task Test -``` +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 +## Project structure (post-init) ``` -{{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 +/ +├── / # 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 ``` -### Available Build Tasks +## Working on the template itself + +If you want to contribute to the template (this repo) rather than use it: ```powershell -./build.ps1 -Help +./build.ps1 -Task Test -Bootstrap ``` -| 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 | +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. -## Contributing +## Requirements -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 +- PowerShell 5.1+ or PowerShell 7+ +- Git +- (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..bf154ca --- /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 + +``` +{{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. From dd64c22e81671bf1e5d362b3b53176ebbd2b2b2d Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:01:12 -0400 Subject: [PATCH 06/10] fix(review): address PR #14 inline findings - ManifestHelpers: strip SemVer build metadata in Split-SemVerString; use [bigint] (not [long]) for numeric prerelease comparison so large identifiers don't overflow. - Initialize-Template: replace 'docs\en-US' and 'tests\Unit\{Public,Private}' with forward-slash child paths so docs/test renaming works on PS7+ Linux/macOS. - Manifest.tests: skip with a clear reason when requirements.psd1 has an empty/whitespace Version, instead of throwing inside Test-VersionConstraint. - {{ModuleName}}.psm1: bare 'throw' in the dot-source catch to preserve the original ErrorRecord (was 'throw $_'). - about_{{ModuleName}}.help.md: drop trailing lone '-' that rendered as an empty bullet under KEYWORDS. - README.md / README.template.md: label project-tree fence as 'text' (markdownlint MD040). Co-Authored-By: Claude Opus 4.7 (1M context) --- Initialize-Template.ps1 | 6 +++--- README.md | 2 +- README.template.md | 2 +- docs/en-US/about_{{ModuleName}}.help.md | 2 -- tests/Manifest.tests.ps1 | 19 +++++++++++++++++-- tests/ManifestHelpers.psm1 | 12 ++++++++---- {{ModuleName}}/{{ModuleName}}.psm1 | 2 +- 7 files changed, 31 insertions(+), 14 deletions(-) diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index 343bf29..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) @@ -263,7 +263,7 @@ if (Test-Path -Path $templateModuleFolder) { } # 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' +$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\}\}' diff --git a/README.md b/README.md index abcdb7f..6d1df4a 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ The script also renames the `{{ModuleName}}` folder, files containing `{{ModuleN ## Project structure (post-init) -``` +```text / ├── / # Module source │ ├── Public/ # Exported functions diff --git a/README.template.md b/README.template.md index bf154ca..49a4628 100644 --- a/README.template.md +++ b/README.template.md @@ -66,7 +66,7 @@ cd {{ModuleName}} ### Project Structure -``` +```text {{ModuleName}}/ ├── {{ModuleName}}/ # Module source │ ├── Public/ # Exported functions diff --git a/docs/en-US/about_{{ModuleName}}.help.md b/docs/en-US/about_{{ModuleName}}.help.md index f6b5d27..55c7a03 100644 --- a/docs/en-US/about_{{ModuleName}}.help.md +++ b/docs/en-US/about_{{ModuleName}}.help.md @@ -15,5 +15,3 @@ ## SEE ALSO ## KEYWORDS - -- diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index e52f6b8..1f33276 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -49,6 +49,11 @@ 'requirementsVersion', Justification = 'false positive' )] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUseDeclaredVarsMoreThanAssignments', + 'candidateVersion', + Justification = 'false positive' +)] param() BeforeDiscovery { @@ -204,10 +209,20 @@ Describe 'Module manifest' { $requirementsVersionSkipReason = 'dependency not found in requirements.psd1' } elseif ($requirements.Item($dependencyName) -is [string]) { # Plain string format: 'ModuleName' = '1.2.3' - $requirementsVersion = $requirements.Item($dependencyName) + $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' } - $requirementsVersion = $requirements.Item($dependencyName).Version + $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" diff --git a/tests/ManifestHelpers.psm1 b/tests/ManifestHelpers.psm1 index 21e694f..5ac5d25 100644 --- a/tests/ManifestHelpers.psm1 +++ b/tests/ManifestHelpers.psm1 @@ -43,7 +43,10 @@ function Split-SemVerString { throw "VersionString cannot be empty or null" } - $parts = $VersionString -split '-', 2 + # 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 } @@ -119,9 +122,10 @@ function Compare-SemVerPrerelease { $secondIsNumeric = $secondId -match '^\d+$' if ($firstIsNumeric -and $secondIsNumeric) { - # Both numeric: compare as integers - $firstNum = [long]$firstId - $secondNum = [long]$secondId + # 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 diff --git a/{{ModuleName}}/{{ModuleName}}.psm1 b/{{ModuleName}}/{{ModuleName}}.psm1 index 6c1fa95..ec2cf09 100644 --- a/{{ModuleName}}/{{ModuleName}}.psm1 +++ b/{{ModuleName}}/{{ModuleName}}.psm1 @@ -6,7 +6,7 @@ foreach ($import in @($public + $private)) { . $import.FullName } catch { Write-Error "Unable to dot source '$($import.FullName)'" - throw $_ + throw } } From 65fd9e47817ef6d20845622bcb5a3ea390824bbd Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:42:01 -0400 Subject: [PATCH 07/10] chore(changelog): rename CHANGELOG.md to CHANGELOG.template.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The existing CHANGELOG.md is the downstream-module starter (contains {{Date}} and {{Prefix}}Example placeholders). Rename it to follow the README.template.md convention so a separate CHANGELOG.md can hold the template's own version history. Initialize-Template.ps1 will swap CHANGELOG.template.md over CHANGELOG.md during init in a follow-up commit. Pure rename — no content change — so git log --follow stays clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md => CHANGELOG.template.md | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename CHANGELOG.md => CHANGELOG.template.md (100%) diff --git a/CHANGELOG.md b/CHANGELOG.template.md similarity index 100% rename from CHANGELOG.md rename to CHANGELOG.template.md From a0e1579f7808461ef9a60036c0839a9aabb7709e Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:43:44 -0400 Subject: [PATCH 08/10] feat(changelog): add template-level CHANGELOG.md (CalVer) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Track changes to the PowerShell module template itself with its own changelog, separate from the downstream-module starter changelog. CHANGELOG.md (new) — template's own history. Uses Calendar Versioning (YYYY.MM.DD) since the template has no API contract for SemVer to describe; date-based answers the question downstream consumers actually ask ("how stale is my init?"). First entry [2026.04.29] captures the five chunks landed in the chore/template-sync branch. Initialize-Template.ps1: - Add CHANGELOG.md to the substitution-loop exclusion list. Without this, init would rewrite literal {{ModuleName}} mentions in the template's history to the user's chosen module name. - After the README.template.md → README.md swap, add a parallel CHANGELOG.template.md → CHANGELOG.md swap so downstream modules receive the placeholder-laden starter changelog (the existing CHANGELOG.template.md, formerly CHANGELOG.md), not the template's own history. tests/Manifest.tests.ps1: - Skip the "Changelog and manifest versions are the same" assertion when running on the un-initialized template, where CHANGELOG.md holds the template's CalVer version which intentionally diverges from the {{ModuleName}}.psd1 ModuleVersion. Marker: presence of CHANGELOG.template.md (exists only pre-init; survives init's substitution loop because nothing in the path matches a placeholder token). The "valid version in changelog" assertion stays — PowerShell parses [Version]'2026.4.29' fine. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 34 ++++++++++++++++++++++++++++++++++ Initialize-Template.ps1 | 10 ++++++++++ tests/Manifest.tests.ps1 | 10 +++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..761ca3a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,34 @@ +# Changelog + +All notable changes to this template will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project uses [Calendar Versioning](https://calver.org/) (`YYYY.MM.DD`). + +For the changelog of a *module initialized from this template*, see the module's +own `CHANGELOG.md` (generated from `CHANGELOG.template.md` during init). + +## [Unreleased] + +## [2026.04.29] - 2026-04-29 + +### Added + +- SemVer-aware dependency version checks: new `tests/ManifestHelpers.psm1` exporting `Test-VersionConstraint`. `tests/Manifest.tests.ps1` now differentiates `RequiredVersion` / `ModuleVersion` / `MaximumVersion`, accepts both string and hashtable shapes in `requirements.psd1`, and detects duplicate `RequiredModules` entries. +- README split: template-facing `README.md` (what GitHub visitors see) and module-facing `README.template.md` (substituted into `README.md` during init). +- `docs/en-US/about_{{ModuleName}}.help.md` stub for `Get-Help about_`. `Initialize-Template.ps1` now also renames `{{ModuleName}}` files in `docs/en-US/`. +- `.gitattributes` marking `docs/en-US/*` as `linguist-generated`. +- `.markdownlint-cli2.jsonc` config (relax MD013 in tables/code, allow MD024 siblings, ignore generated docs and `instructions/`). + +### Changed + +- `PSScriptAnalyzerSettings.psd1`: replaced one-line `@{ IncludeRules = @('*') }` with the structured form (Include/Exclude/Rules + commented compat scaffold). +- Bumped `PSScriptAnalyzer` 1.24.0 → 1.25.0. + +### Fixed + +- `tests/Help.tests.ps1`: replaced undefined `$parameterNames` with `$commandParameterNames` in the help-vs-code parameter check (was silently asserting against `$null`). +- `{{ModuleName}}/{{ModuleName}}.psm1`: dot-source catch block now preserves the original `ErrorRecord` via bare `throw` (was `throw $_`, which wrapped the error). + +[Unreleased]: https://github.com/tablackburn/PowerShellModuleTemplate/compare/v2026.04.29...HEAD +[2026.04.29]: https://github.com/tablackburn/PowerShellModuleTemplate/releases/tag/v2026.04.29 diff --git a/Initialize-Template.ps1 b/Initialize-Template.ps1 index d3b2a1c..28d23f0 100644 --- a/Initialize-Template.ps1 +++ b/Initialize-Template.ps1 @@ -180,6 +180,7 @@ $filesToProcess = Get-ChildItem -Path $PSScriptRoot -Recurse -File | Where-Objec $_.FullName -notmatch '[\\/]Output[\\/]' -and $_.FullName -notmatch '[\\/]out[\\/]' -and $_.Name -ne 'Initialize-Template.ps1' -and + $_.Name -ne 'CHANGELOG.md' -and $_.Extension -in @('.ps1', '.psm1', '.psd1', '.md', '.json', '.yml', '.yaml', '.xml', '.txt', '') } @@ -287,6 +288,15 @@ if (Test-Path -Path $readmeTemplate) { Write-Host ' Generated module README.md from template' -ForegroundColor Green } +# Replace template-facing CHANGELOG.md with the module-facing CHANGELOG.template.md +# (placeholders inside CHANGELOG.template.md were already substituted by the file-processing loop above) +$changelogTemplate = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.template.md' +$changelogPath = Join-Path -Path $PSScriptRoot -ChildPath 'CHANGELOG.md' +if (Test-Path -Path $changelogTemplate) { + Move-Item -Path $changelogTemplate -Destination $changelogPath -Force + Write-Host ' Generated module CHANGELOG.md from template' -ForegroundColor Green +} + # Initialize Git repository if requested if (-not $NoGitInit) { $gitFolder = Join-Path -Path $PSScriptRoot -ChildPath '.git' diff --git a/tests/Manifest.tests.ps1 b/tests/Manifest.tests.ps1 index 1f33276..fe6e3f6 100644 --- a/tests/Manifest.tests.ps1 +++ b/tests/Manifest.tests.ps1 @@ -86,6 +86,14 @@ BeforeDiscovery { } $manifestData = Test-ModuleManifest @testModuleManifestParameters $dependencies = $manifestData.RequiredModules + + # When running on the un-initialized template, CHANGELOG.md tracks the template's + # CalVer version (YYYY.MM.DD), which deliberately decouples from the manifest's + # ModuleVersion. Skip the equality assertion in that case; downstream modules (post-init) + # keep the assertion. Marker: CHANGELOG.template.md exists only pre-init — + # Initialize-Template.ps1 moves it onto CHANGELOG.md during init. The marker survives + # the init substitution loop because no token in the path matches a {{Placeholder}}. + $isTemplate = Test-Path -LiteralPath (Join-Path -Path $Env:BHProjectPath -ChildPath 'CHANGELOG.template.md') } BeforeAll { # Check if the BHBuildOutput environment variable exists to determine if this test is running in a psake @@ -180,7 +188,7 @@ Describe 'Module manifest' { $changelogVersion -as [Version] | Should -Not -BeNullOrEmpty } - It 'Changelog and manifest versions are the same' { + It 'Changelog and manifest versions are the same' -Skip:$isTemplate { $changelogVersion -as [Version] | Should -Be ( $manifestData.Version -as [Version] ) } From 90ecacb70c2b0ada951f6ba5d40201aed62d155b Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 18:44:13 -0400 Subject: [PATCH 09/10] docs: link template CHANGELOG from README.md Add a Changelog section to the template-facing README so visitors of the template repo on GitHub can discover the version history. Inserted between Requirements and License, with a one-line note that the template uses CalVer rather than SemVer (so reviewers don't read date-shaped numbers as broken SemVer). Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 6d1df4a..86c3eba 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ The test suite runs against the `{{ModuleName}}` placeholder module to verify th - Git - (Optional) Docker for the devcontainer +## Changelog + +See [CHANGELOG.md](CHANGELOG.md) for the template's version history. The template uses [Calendar Versioning](https://calver.org/) (`YYYY.MM.DD`) — the version reflects when a snapshot of the template was cut, not API compatibility. + ## License [MIT](LICENSE) From 2a29c6b240d4d83b6840d27dc9df9ea5a9899589 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 29 Apr 2026 20:20:47 -0400 Subject: [PATCH 10/10] docs(changelog): correct dot-source catch block before/after description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Fixed entry described the change as `throw $_` → bare `throw`, but `throw $_` was an intermediate state inside PR #14's review iterations. The actual user-visible change from main was: a string `throw` (which wrapped the original error in a fresh ErrorRecord) → bare `throw` (which preserves the original ErrorRecord and stack trace). Addresses Copilot inline review comment on PR #15. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 761ca3a..79a9853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,7 +28,7 @@ own `CHANGELOG.md` (generated from `CHANGELOG.template.md` during init). ### Fixed - `tests/Help.tests.ps1`: replaced undefined `$parameterNames` with `$commandParameterNames` in the help-vs-code parameter check (was silently asserting against `$null`). -- `{{ModuleName}}/{{ModuleName}}.psm1`: dot-source catch block now preserves the original `ErrorRecord` via bare `throw` (was `throw $_`, which wrapped the error). +- `{{ModuleName}}/{{ModuleName}}.psm1`: dot-source catch block now preserves the original `ErrorRecord` via bare `throw`. Previously the catch threw a new string (`"Unable to dot source ..."`), which wrapped the original exception in a fresh `ErrorRecord` and lost the underlying stack trace. [Unreleased]: https://github.com/tablackburn/PowerShellModuleTemplate/compare/v2026.04.29...HEAD [2026.04.29]: https://github.com/tablackburn/PowerShellModuleTemplate/releases/tag/v2026.04.29