diff --git a/CHANGELOG.md b/CHANGELOG.md index b3455b5..359d4ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `Add-PSDepend` appends a dependency to a DependencyFile, creating + `requirements.psd1` if none exists. It parses the target file with the + PowerShell AST and splices the new entry in as text, leaving every other + entry and any comments untouched. By default it re-runs + `Update-PSDependLock` afterward, rolling back the edit if resolution + fails; `-NoLock` opts out. `-Force` overwrites an existing entry for the + same Name, replacing it entirely. +- `PSGalleryModule` now warns when `Version` is an exact prerelease version + (e.g. `0.7.0-beta1`, including SemVer build metadata like `1.0.0-beta+build.5`) + but `AllowPrerelease` is not set, since `Install-Module` will otherwise fail + to find the package. The detection heuristic lives in the new + `Test-PSDependPrereleaseVersion` private helper (a regex, not + `SemanticVersion`, since that type isn't available on the inbox Windows + PowerShell 5.1 assembly this module targets). + ## [0.7.0-beta1] - 2026-10-07 ### Added diff --git a/PSDepend/PSDepend.psd1 b/PSDepend/PSDepend.psd1 index 6a6bc87..5cea64f 100644 --- a/PSDepend/PSDepend.psd1 +++ b/PSDepend/PSDepend.psd1 @@ -60,6 +60,7 @@ # Functions to export from this module # Generated by the build process FunctionsToExport = @( + 'Add-PSDepend', 'Get-Dependency', 'Get-PSDependScript', 'Get-PSDependType', diff --git a/PSDepend/PSDependScripts/PSGalleryModule.ps1 b/PSDepend/PSDependScripts/PSGalleryModule.ps1 index 01deed8..d3fa848 100644 --- a/PSDepend/PSDependScripts/PSGalleryModule.ps1 +++ b/PSDepend/PSDependScripts/PSGalleryModule.ps1 @@ -9,6 +9,7 @@ Name: The name for this module Version: Used to identify existing installs meeting this criteria, and as RequiredVersion for installation. Defaults to 'latest' Also accepts a NuGet version range (e.g. '[2.2.3,3.0)', '[2.0,)', '(,3.0)'). A bare version (e.g. '3.2.1') still means that exact version. When a range is given, the highest available version that satisfies it is installed. + A bare exact version that looks like a prerelease (e.g. '0.7.0-beta1') triggers a warning unless AllowPrerelease is also specified. Target: Used as 'Scope' for Install-Module. If this is a path, we use Save-Module with this path. On reruns, PSDepend checks existing modules first and skips reinstalling when the requested version is already present. Defaults to 'AllUsers' AddToPath: If target is used as a path, prepend that path to ENV:PSModulePath Credential: The username and password used to authenticate against the private repository @@ -217,6 +218,10 @@ if ($Version -and $Version -ne 'latest') { } if ($versionRange.IsExact) { $params.Add('RequiredVersion', $versionRange.Exact) + + if ((Test-PSDependPrereleaseVersion -Version $versionRange.Exact) -and -not $AllowPrerelease) { + Write-Warning "Version [$($versionRange.Exact)] for [$Name] looks like a prerelease version, but AllowPrerelease was not specified. Add 'AllowPrerelease = `$true' to Parameters, or the module may not be found." + } } } diff --git a/PSDepend/Private/ConvertTo-PSDependLiteral.ps1 b/PSDepend/Private/ConvertTo-PSDependLiteral.ps1 new file mode 100644 index 0000000..1ee6fd8 --- /dev/null +++ b/PSDepend/Private/ConvertTo-PSDependLiteral.ps1 @@ -0,0 +1,60 @@ +function ConvertTo-PSDependLiteral { + <# + .SYNOPSIS + Render a value as PowerShell source text suitable for a DependencyFile entry. + + .DESCRIPTION + Used by Add-PSDepend to serialize field values (Tags, Parameters, ...) when + writing a new hashtable-form entry. Handles strings, booleans, numbers, + arrays, and nested hashtables. + + .PARAMETER Value + The value to render. + + .PARAMETER IndentLevel + Indentation depth, in 4-space units, for a nested hashtable's members and + its closing brace. + + .EXAMPLE + ConvertTo-PSDependLiteral -Value @{ SkipPublisherCheck = $true } + + Returns "@{`r`n SkipPublisherCheck = `$true`r`n }" + #> + [CmdletBinding()] + [OutputType([string])] + param( + $Value, + + [int]$IndentLevel = 2 + ) + + if ($null -eq $Value) { + return '$null' + } + if ($Value -is [bool]) { + return $(if ($Value) { '$true' } else { '$false' }) + } + if ($Value -is [System.Collections.IDictionary]) { + $ChildIndent = ' ' * ($IndentLevel + 1) + $ClosingIndent = ' ' * $IndentLevel + $Entries = foreach ($Key in $Value.Keys) { + $QuotedKey = "'$($Key.ToString() -replace "'", "''")'" + "$ChildIndent$QuotedKey = $(ConvertTo-PSDependLiteral -Value $Value[$Key] -IndentLevel ($IndentLevel + 1))" + } + return "@{`r`n$($Entries -join "`r`n")`r`n$ClosingIndent}" + } + if ($Value -is [string]) { + return "'$($Value -replace "'", "''")'" + } + if ($Value -is [int] -or $Value -is [long] -or $Value -is [double]) { + return "$Value" + } + if ($Value -is [System.Collections.IEnumerable]) { + $Items = @($Value | ForEach-Object { ConvertTo-PSDependLiteral -Value $_ -IndentLevel $IndentLevel }) + if ($Items.Count -eq 0) { + return '@()' + } + return ($Items -join ', ') + } + return "'$($Value.ToString() -replace "'", "''")'" +} diff --git a/PSDepend/Private/Resolve-PSDependFileTarget.ps1 b/PSDepend/Private/Resolve-PSDependFileTarget.ps1 new file mode 100644 index 0000000..234ec39 --- /dev/null +++ b/PSDepend/Private/Resolve-PSDependFileTarget.ps1 @@ -0,0 +1,61 @@ +function Resolve-PSDependFileTarget { + <# + .SYNOPSIS + Resolve the DependencyFile that Add-PSDepend should write to. + + .DESCRIPTION + If Path points to an existing file, it is used as-is. If Path points to a + nonexistent path with a .psd1 extension, it is treated as the file to + create. Otherwise Path is treated as a directory: it is searched + (optionally recursively) for *.depend.psd1 and requirements.psd1 files, + the same way Invoke-PSDepend and Update-PSDependLock discover + DependencyFiles. Exactly one match is returned as-is. No matches falls + back to \requirements.psd1, to be created by the caller. More than + one match is ambiguous and the caller must narrow it with -Path. + + .PARAMETER Path + A DependencyFile, a new DependencyFile to create, or a directory to search. + + .PARAMETER Recurse + Whether to recurse into subdirectories when Path is a directory. + + .EXAMPLE + Resolve-PSDependFileTarget -Path . -Recurse $true + + Finds the single DependencyFile under the current directory tree, or + falls back to .\requirements.psd1 if none exist yet. + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [string]$Path, + + [bool]$Recurse = $true + ) + + if (Test-Path -LiteralPath $Path -PathType Leaf) { + $Resolved = (Resolve-Path -LiteralPath $Path).ProviderPath + if ([System.IO.Path]::GetExtension($Resolved) -ine '.psd1') { + throw "Path '$Resolved' is not a .psd1 file" + } + return $Resolved + } + + if (-not (Test-Path -LiteralPath $Path)) { + if ([System.IO.Path]::GetExtension($Path) -eq '.psd1') { + return $ExecutionContext.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) + } + throw "Path '$Path' does not exist" + } + + $DirectoryPath = (Resolve-Path -LiteralPath $Path).ProviderPath + $Found = @(Resolve-DependScripts -Path $DirectoryPath -Recurse $Recurse) + switch ($Found.Count) { + 0 { Join-Path $DirectoryPath 'requirements.psd1' } + 1 { $Found[0] } + default { + throw "Multiple DependencyFiles found under '$DirectoryPath': $($Found -join ', '). Specify -Path to target one of them." + } + } +} diff --git a/PSDepend/Private/Test-PSDependPrereleaseVersion.ps1 b/PSDepend/Private/Test-PSDependPrereleaseVersion.ps1 new file mode 100644 index 0000000..854f18d --- /dev/null +++ b/PSDepend/Private/Test-PSDependPrereleaseVersion.ps1 @@ -0,0 +1,50 @@ +function Test-PSDependPrereleaseVersion { + <# + .SYNOPSIS + Heuristically test whether an exact version string looks like a prerelease. + + .DESCRIPTION + Returns $true when Version has a dash-delimited suffix after 1-4 numeric + dot-separated components (e.g. '0.7.0-beta1', '2.5.0-alpha', or with SemVer + 2.0 build metadata like '1.0.0-beta+build.5'). Used to warn callers who + pinned a prerelease version but didn't pass AllowPrerelease. + + This is a heuristic, not a strict validity check: it is intentionally + looser than SemVer (it accepts 4 numeric components, and doesn't enforce + per-identifier SemVer grammar such as "no leading zero"), because a false + positive here only produces an extra warning, while a false negative + silently lets Install-Module fail to find the module. See + docs/PSDependScripts-PrereleaseVersionDetection.md for the full version + matrix this was validated against. + + Deliberately does not use [System.Management.Automation.SemanticVersion]: + that type isn't available in the inbox Windows PowerShell 5.1 assembly + (confirmed via PSScriptAnalyzer's UseCompatibleTypes rule, which uses this + exact type as its worked example of a type missing from the 5.1 profile), + and PSDepend's manifest declares PowerShellVersion = '5.1'. A regex has no + such runtime-availability risk. + + .PARAMETER Version + An exact (non-range) version string to test. + + .EXAMPLE + Test-PSDependPrereleaseVersion -Version '0.7.0-beta1' + + Returns $true. + + .EXAMPLE + Test-PSDependPrereleaseVersion -Version '1.2.3' + + Returns $false - no prerelease suffix. + #> + [CmdletBinding()] + [OutputType([bool])] + param( + [AllowNull()] + [string]$Version + ) + + if ([string]::IsNullOrWhiteSpace($Version)) { return $false } + + $Version -match '^\d+(\.\d+){1,3}-[0-9A-Za-z][0-9A-Za-z.+-]*$' +} diff --git a/PSDepend/Public/Add-PSDepend.ps1 b/PSDepend/Public/Add-PSDepend.ps1 new file mode 100644 index 0000000..93a15d0 --- /dev/null +++ b/PSDepend/Public/Add-PSDepend.ps1 @@ -0,0 +1,359 @@ +function Add-PSDepend { + <# + .SYNOPSIS + Add a dependency to a DependencyFile. + + .DESCRIPTION + Add a dependency to a DependencyFile. + + Add-PSDepend parses the target DependencyFile with the PowerShell AST + and splices the new entry in as text, leaving every other entry and + any comments byte-for-byte untouched; the file's existing encoding + (including BOM) and newline style are also preserved. By default it + then re-locks the file with Update-PSDependLock, so the + DependencyFile and its requirements.lock.json never drift apart; if + the lock step fails, the DependencyFile edit is rolled back. Use + -NoLock to skip that step for offline or CI use. + + Add-PSDepend only declares the dependency; it does not install it. + Run Invoke-PSDepend afterward to install. + + New entries are written as the terse 'Name' = 'Version' form when only + Name and Version were given and the DependencyType resolves to + PSGalleryModule (the only type the terse form can round-trip through + Get-Dependency's shorthand parsing); any other field, or any other + DependencyType, writes the full hashtable form instead. The psd1 key + is always the bare Name: a dependency of a different DependencyType + but the same Name is treated as a collision like any other. + + See Get-Help about_PSDepend for more information. + + .PARAMETER Name + Name of the dependency. Becomes the DependencyName: the psd1 key. + Entries Add-PSDepend writes never include a separate Name field, so + dependency scripts that need a package or repo name (and fall back to + DependencyName when Name is absent) read it from this key. + + .PARAMETER Version + Version (or NuGet-style range) for the dependency. + + Defaults to 'latest'. + + .PARAMETER DependencyType + Type of dependency. See Get-PSDependType. + + If not specified, defaults to the target file's PSDependOptions.DependencyType + if one is set, then to GitHub or Git if Name looks like 'owner/repo' or + contains a '/', and finally to PSGalleryModule. + + .PARAMETER Source + Source for this dependency. Usage depends on the DependencyType. + + .PARAMETER Target + Target for this dependency. Usage depends on the DependencyType. + + .PARAMETER Tags + One or more tags to categorize or filter this dependency. + + .PARAMETER Parameters + A hashtable of parameters to splat to the DependencyType's script. + + .PARAMETER DependsOn + DependencyName that must run before this one. + + .PARAMETER PreScripts + One or more paths to PowerShell scripts to run before this dependency. + + .PARAMETER PostScripts + One or more paths to PowerShell scripts to run after this dependency. + + .PARAMETER AddToPath + Prepend the resulting folder to $ENV:Path and $ENV:PSModulePath. + + .PARAMETER Credential + Name of a credential. Must match a key in the hashtable later passed + to Invoke-PSDepend's or Update-PSDependLock's -Credentials parameter. + + .PARAMETER Path + A DependencyFile, a new DependencyFile to create, or a directory to + search for one. + + Defaults to the current path. If Path is a directory, it is searched + for *.depend.psd1 and requirements.psd1 files: exactly one match is + used as-is, no matches falls back to creating \requirements.psd1, + and more than one match is an error asking you to narrow it with -Path. + + .PARAMETER Recurse + If Path is a directory, whether to recursively search it for + *.depend.psd1 and requirements.psd1 files. + + Defaults to $True. + + .PARAMETER PSDependTypePath + Specify a PSDependMap.psd1 file that maps DependencyTypes to their scripts. + + This defaults to the PSDependMap.psd1 in the PSDepend module folder + + .PARAMETER Credentials + Specifies a hashtable of PSCredentials to use for each dependency that + is served from a private feed, for the Update-PSDependLock step this + command runs by default. The key of the hashtable must match the + Credential property value in the dependency. Ignored with -NoLock. + + .PARAMETER Force + If a dependency with this Name already exists in the DependencyFile, + overwrite it. The existing entry is fully replaced: any field not + passed this time is dropped, not carried over. + + .PARAMETER NoLock + Skip the Update-PSDependLock step after writing the DependencyFile. + The lock, if one exists, is left stale. + + .PARAMETER PassThru + Return the path of the DependencyFile that was written. + + .EXAMPLE + Add-PSDepend psake latest + + # Add 'psake' = 'latest' to .\requirements.psd1, creating it if needed, + # then update its lock + + .EXAMPLE + Add-PSDepend -DependencyType GitHub -Name 'RamblingCookieMonster/PowerShell' -Version main + + # Add a GitHub dependency in full hashtable form + + .EXAMPLE + Add-PSDepend Pester 5.9.0 -Parameters @{ SkipPublisherCheck = $true } -Force + + # Overwrite the existing 'Pester' entry, replacing it entirely + + .EXAMPLE + Add-PSDepend psake latest -Path .\requirements.psd1 -NoLock -WhatIf + + # Preview the change without touching disk or re-locking + + .LINK + https://github.com/PowerShellOrg/PSDepend + #> + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSAvoidUsingPlainTextForPassword', + '', + Justification = 'Credential is the name of a credential key to look up in -Credentials, not a credential or password itself; matches the DependencyFile schema''s own Credential field.' + )] + [Diagnostics.CodeAnalysis.SuppressMessageAttribute( + 'PSUsePSCredentialType', + '', + Justification = 'Credential is the name of a credential key to look up in -Credentials, not a credential or password itself; matches the DependencyFile schema''s own Credential field.' + )] + [CmdletBinding(SupportsShouldProcess = $True)] + [OutputType([string])] + param( + [Parameter(Mandatory, Position = 0)] + [ValidateNotNullOrEmpty()] + [string]$Name, + + [Parameter(Position = 1)] + [string]$Version = 'latest', + + [string]$DependencyType, + + [string]$Source, + + [string]$Target, + + [string[]]$Tags, + + [hashtable]$Parameters, + + [string[]]$DependsOn, + + [string[]]$PreScripts, + + [string[]]$PostScripts, + + [switch]$AddToPath, + + [string]$Credential, + + [string]$Path = '.', + + [bool]$Recurse = $True, + + [validatescript( { Test-Path -Path $_ -PathType Leaf -ErrorAction Stop })] + [string]$PSDependTypePath = $(Join-Path $ModuleRoot PSDependMap.psd1), + + [hashtable]$Credentials, + + [switch]$Force, + + [switch]$NoLock, + + [switch]$PassThru + ) + process { + if ($Name -ieq 'PSDependOptions') { + throw "'PSDependOptions' is reserved and cannot be used as a dependency Name" + } + + $DependencyFile = Resolve-PSDependFileTarget -Path $Path -Recurse $Recurse + $FileExisted = Test-Path -LiteralPath $DependencyFile -PathType Leaf + $NoBomUtf8 = [System.Text.UTF8Encoding]::new($false) + + if ($FileExisted) { + # detectEncodingFromByteOrderMarks: if the file has a BOM, CurrentEncoding + # reflects it; if not, it falls back to $NoBomUtf8, so round-tripping + # through $FileEncoding preserves the file's original encoding either way. + $Reader = [System.IO.StreamReader]::new($DependencyFile, $NoBomUtf8, $true) + try { + $OriginalFileText = $Reader.ReadToEnd() + $FileEncoding = $Reader.CurrentEncoding + } + finally { + $Reader.Dispose() + } + } + else { + $OriginalFileText = $null + $FileEncoding = $NoBomUtf8 + } + + if ([string]::IsNullOrWhiteSpace($OriginalFileText)) { + $ExistingData = @{} + $FileText = "@{`r`n}`r`n" + } + else { + $Base = Split-Path -Path $DependencyFile -Parent + $Leaf = Split-Path -Path $DependencyFile -Leaf + $ExistingData = Import-LocalizedData -BaseDirectory $Base -FileName $Leaf + $FileText = $OriginalFileText + } + + $NewlineStyle = if ($FileText -match "`r`n") { "`r`n" } elseif ($FileText -match "`n") { "`n" } else { "`r`n" } + + $ExistingKey = $null + foreach ($Key in $ExistingData.Keys) { + if ($Key -ine 'PSDependOptions' -and $Key -ieq $Name) { + $ExistingKey = $Key + break + } + } + if ($ExistingKey -and -not $Force) { + throw "Dependency '$Name' already exists in '$DependencyFile'. Use -Force to overwrite it." + } + + $EffectiveType = + if ($PSBoundParameters.ContainsKey('DependencyType')) { + $DependencyType + } + elseif ($ExistingData.PSDependOptions -and $ExistingData.PSDependOptions.DependencyType) { + $ExistingData.PSDependOptions.DependencyType + } + elseif ($Name -match '/' -and @($Name -split '/').Count -eq 2) { + 'GitHub' + } + elseif ($Name -match '/') { + 'Git' + } + else { + 'PSGalleryModule' + } + + $KnownTypes = @(Get-PSDependType -Path $PSDependTypePath -SkipHelp | Select-Object -ExpandProperty DependencyType) + if ($EffectiveType -notin $KnownTypes) { + throw "DependencyType '$EffectiveType' is not defined in '$PSDependTypePath'. Known types: $($KnownTypes -join ', ')" + } + + $ExtraFieldNames = 'DependencyType', 'Source', 'Target', 'Tags', 'Parameters', 'DependsOn', 'PreScripts', 'PostScripts', 'AddToPath', 'Credential' + $HasExtraFields = @($ExtraFieldNames | Where-Object { $PSBoundParameters.ContainsKey($_) }).Count -gt 0 + $UseTerseForm = -not $HasExtraFields -and $EffectiveType -eq 'PSGalleryModule' + + if ($UseTerseForm) { + $EntryKeyValueText = "'$($Name -replace "'", "''")' = '$($Version -replace "'", "''")'" + } + else { + $Fields = [ordered]@{ + DependencyType = $EffectiveType + Version = $Version + } + if ($PSBoundParameters.ContainsKey('Source')) { $Fields.Source = $Source } + if ($PSBoundParameters.ContainsKey('Target')) { $Fields.Target = $Target } + if ($PSBoundParameters.ContainsKey('Tags')) { $Fields.Tags = $Tags } + if ($PSBoundParameters.ContainsKey('DependsOn')) { $Fields.DependsOn = $DependsOn } + if ($PSBoundParameters.ContainsKey('PreScripts')) { $Fields.PreScripts = $PreScripts } + if ($PSBoundParameters.ContainsKey('PostScripts')) { $Fields.PostScripts = $PostScripts } + if ($AddToPath) { $Fields.AddToPath = $true } + if ($PSBoundParameters.ContainsKey('Credential')) { $Fields.Credential = $Credential } + if ($PSBoundParameters.ContainsKey('Parameters')) { $Fields.Parameters = $Parameters } + + $FieldLines = foreach ($FieldName in $Fields.Keys) { + " $FieldName = $(ConvertTo-PSDependLiteral -Value $Fields[$FieldName] -IndentLevel 2)" + } + $EntryKeyValueText = "'$($Name -replace "'", "''")' = @{`r`n$($FieldLines -join "`r`n")`r`n }" + } + + $tokens = $null + $parseErrors = $null + $Ast = [System.Management.Automation.Language.Parser]::ParseInput($FileText, [ref]$tokens, [ref]$parseErrors) + if ($parseErrors) { + throw "DependencyFile '$DependencyFile' has a syntax error and cannot be edited automatically: $($parseErrors[0].Message)" + } + $Pipeline = $Ast.EndBlock.Statements | Where-Object { $_ -is [System.Management.Automation.Language.PipelineAst] } | Select-Object -First 1 + $HashtableAst = if ($Pipeline) { $Pipeline.PipelineElements[0].Expression } + if ($HashtableAst -isnot [System.Management.Automation.Language.HashtableAst]) { + throw "DependencyFile '$DependencyFile' must contain a single hashtable literal (@{ ... }) at the top level" + } + + $EntryKeyValueText = $EntryKeyValueText -replace "`r`n", $NewlineStyle + + if ($ExistingKey) { + $Match = $HashtableAst.KeyValuePairs | Where-Object { $_.Item1.Value -ieq $Name } | Select-Object -First 1 + $NewText = $FileText.Substring(0, $Match.Item1.Extent.StartOffset) + $EntryKeyValueText + $FileText.Substring($Match.Item2.Extent.EndOffset) + } + else { + $InsertPos = $HashtableAst.Extent.EndOffset - 1 + $Before = $FileText.Substring(0, $InsertPos) + $After = $FileText.Substring($InsertPos) + $Separator = if ($Before.Length -eq 0 -or $Before.EndsWith($NewlineStyle)) { '' } else { $NewlineStyle } + $NewText = $Before + $Separator + " $EntryKeyValueText" + $NewlineStyle + $After + } + + if (-not $PSCmdlet.ShouldProcess($DependencyFile, "Add dependency '$Name'")) { + return + } + + $ParentDir = Split-Path -Path $DependencyFile -Parent + if ($ParentDir -and -not (Test-Path -LiteralPath $ParentDir)) { + $null = New-Item -ItemType Directory -Path $ParentDir -Force + } + # .NET WriteAllText throws on failure (unlike Set-Content's non-terminating + # errors), so a failed write never falls through into the lock step below. + [System.IO.File]::WriteAllText($DependencyFile, $NewText, $FileEncoding) + + if (-not $NoLock) { + try { + $LockParams = @{ + Path = $DependencyFile + PSDependTypePath = $PSDependTypePath + } + if ($null -ne $Credentials) { + $LockParams.Credentials = $Credentials + } + $null = Update-PSDependLock @LockParams -Confirm:$false + } + catch { + if ($FileExisted) { + [System.IO.File]::WriteAllText($DependencyFile, $OriginalFileText, $FileEncoding) + } + else { + Remove-Item -LiteralPath $DependencyFile -Force -ErrorAction SilentlyContinue + } + throw "Added '$Name' to '$DependencyFile' but failed to update its lock, so the edit was rolled back: $_" + } + } + + if ($PassThru) { + $DependencyFile + } + } +} diff --git a/PSDepend/en-US/about_PSDepend.help.txt b/PSDepend/en-US/about_PSDepend.help.txt index f3906f8..64a6436 100644 --- a/PSDepend/en-US/about_PSDepend.help.txt +++ b/PSDepend/en-US/about_PSDepend.help.txt @@ -105,6 +105,24 @@ DETAILED DESCRIPTION Position? 2 Default value PSGallery + Adding dependencies + =================== + Add-PSDepend appends a dependency to a DependencyFile, creating + requirements.psd1 if none exists yet: + + Add-PSDepend psake latest + Add-PSDepend -DependencyType GitHub -Name 'RamblingCookieMonster/PowerShell' -Version main + + It parses the file with the PowerShell AST and splices the new entry in + as text, leaving every other entry and any comments untouched. By + default it also re-runs Update-PSDependLock afterward so the + DependencyFile and its lock never drift apart; -NoLock skips that. If + the lock step fails, the DependencyFile edit is rolled back. + + Add-PSDepend only declares the dependency; it does not install it. Run + Invoke-PSDepend afterward to install. Adding a Name that already exists + is an error unless -Force is passed, which fully replaces the entry. + Locking dependencies ==================== Like npm's package-lock.json, PSDepend can pin every dependency, and the diff --git a/README.md b/README.md index 2217981..92d171d 100644 --- a/README.md +++ b/README.md @@ -149,6 +149,28 @@ Invoke-PSDepend -Path C:\requirements.psd1 -Credentials @{ 'my_gallery' = $creds The credential key must match between the dependency definition and the hashtable passed to `-Credentials`. +## Adding Dependencies with Add-PSDepend + +`Add-PSDepend` appends a dependency to a DependencyFile in a PowerShell-y +way instead of hand-editing the `.psd1`, creating `requirements.psd1` if +none exists yet: + +```powershell +Add-PSDepend psake latest +Add-PSDepend -DependencyType GitHub -Name 'RamblingCookieMonster/PowerShell' -Version main +``` + +It parses the file with the PowerShell AST and splices the new entry in as +text, so every other entry — and any comments — are left untouched. By +default it also re-runs `Update-PSDependLock` afterward so the DependencyFile +and its lock never drift apart; pass `-NoLock` to skip that (e.g. offline or +in CI). If the lock step fails, the DependencyFile edit is rolled back. + +`Add-PSDepend` only declares the dependency — it does not install it. Run +`Invoke-PSDepend` afterward to install. Calling it again for a `Name` that +already exists is an error unless you pass `-Force`, which fully replaces +the existing entry. + ## Locking Dependencies Like npm's `package-lock.json`, PSDepend can pin every dependency — and the dependencies *they* pull in — to exact versions so that every machine installs the same thing: diff --git a/Tests/Add-PSDepend.Tests.ps1 b/Tests/Add-PSDepend.Tests.ps1 new file mode 100644 index 0000000..9e0c46c --- /dev/null +++ b/Tests/Add-PSDepend.Tests.ps1 @@ -0,0 +1,356 @@ +# cspell:ignore nomatch +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force + + # A map that exposes the fake Resolve-capable type alongside Noop (which cannot Resolve) + $fakeScript = (Resolve-Path (Join-Path $PSScriptRoot 'Shared/FakeResolver.ps1')).Path + $script:MapPath = Join-Path $TestDrive 'Add.PSDependMap.psd1' + @" +@{ + FakeResolver = @{ + Script = '$fakeScript' + Description = 'Static graph for Add-PSDepend tests' + Supports = 'windows', 'core', 'macos', 'linux' + } + Noop = @{ + Script = 'Noop.ps1' + Description = 'Noop' + Supports = 'windows', 'core', 'macos', 'linux' + } +} +"@ | Set-Content -Path $script:MapPath + + function Initialize-AddProject { + param([string]$Name, [string]$Body) + $dir = Join-Path $TestDrive $Name + $null = New-Item -ItemType Directory -Path $dir -Force + if ($PSBoundParameters.ContainsKey('Body')) { + $file = Join-Path $dir 'requirements.psd1' + Set-Content -Path $file -Value $Body -NoNewline + } + $dir + } +} + +Describe 'Add-PSDepend file discovery' { + It 'Creates requirements.psd1 when none exists under Path' { + $dir = Initialize-AddProject -Name 'new-file' + + $resultPath = Add-PSDepend -Path $dir -Name psake -Version latest -NoLock -PassThru + + $resultPath | Should -Be (Join-Path $dir 'requirements.psd1') + Test-Path -LiteralPath $resultPath | Should -BeTrue + (Get-Content -LiteralPath $resultPath -Raw) | Should -Match "'psake' = 'latest'" + } + + It 'Uses the single existing DependencyFile under Path' { + $dir = Initialize-AddProject -Name 'single-file' -Body "@{`r`n Existing = 'latest'`r`n}`r`n" + + $resultPath = Add-PSDepend -Path $dir -Name psake -Version latest -NoLock -PassThru + + $resultPath | Should -Be (Join-Path $dir 'requirements.psd1') + } + + It 'Throws when multiple DependencyFiles are found, asking for -Path' { + $dir = Initialize-AddProject -Name 'ambiguous' + Set-Content -Path (Join-Path $dir 'first.depend.psd1') -Value '@{}' + Set-Content -Path (Join-Path $dir 'second.depend.psd1') -Value '@{}' + + { Add-PSDepend -Path $dir -Name psake -NoLock } | Should -Throw -ExpectedMessage '*Multiple DependencyFiles*-Path*' + } + + It 'Writes directly to an explicit new file path with a .psd1 extension' { + $dir = Initialize-AddProject -Name 'explicit-new' + $target = Join-Path $dir 'custom.depend.psd1' + + $resultPath = Add-PSDepend -Path $target -Name psake -NoLock -PassThru + + $resultPath | Should -Be $target + Test-Path -LiteralPath $target | Should -BeTrue + } + + It 'Rejects an existing file that is not a .psd1' { + $dir = Initialize-AddProject -Name 'wrong-extension' + $target = Join-Path $dir 'requirements.json' + Set-Content -Path $target -Value '{}' + + { Add-PSDepend -Path $target -Name psake -NoLock } | Should -Throw -ExpectedMessage '*not a .psd1 file*' + } +} + +Describe 'Add-PSDepend entry format' { + It 'Writes the terse string form for Name+Version with the default PSGalleryModule type' { + $dir = Initialize-AddProject -Name 'terse' + + $file = Add-PSDepend -Path $dir -Name psake -Version '4.9.1' -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'psake' = '4\.9\.1'" + $content | Should -Not -Match 'DependencyType' + } + + It 'Writes the full hashtable form when an extra field is given' { + $dir = Initialize-AddProject -Name 'hashtable-extra' + + $file = Add-PSDepend -Path $dir -Name Pester -Version '5.9.0' -Parameters @{ SkipPublisherCheck = $true } -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'Pester' = @\{" + $content | Should -Match "DependencyType = 'PSGalleryModule'" + $content | Should -Match "'SkipPublisherCheck' = \`$true" + } + + It 'Writes the full hashtable form when the effective DependencyType is not PSGalleryModule' { + $dir = Initialize-AddProject -Name 'hashtable-type' + + $file = Add-PSDepend -Path $dir -Name App -DependencyType FakeResolver -PSDependTypePath $script:MapPath -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'App' = @\{" + $content | Should -Match "DependencyType = 'FakeResolver'" + } + + It 'Quotes a Parameters key that is not a valid bareword identifier' { + $dir = Initialize-AddProject -Name 'hashtable-key-quoting' + + $file = Add-PSDepend -Path $dir -Name Pester -Parameters @{ 'Display Name' = 'x' } -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'Display Name' = 'x'" + } + + It 'Serializes an empty Tags array as an empty array literal, not blank' { + $dir = Initialize-AddProject -Name 'empty-array' + + $file = Add-PSDepend -Path $dir -Name Pester -Tags @() -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match 'Tags\s*=\s*@\(\)' + + # and the result must still be valid, parseable PowerShell data + $tokens = $null + $errors = $null + [System.Management.Automation.Language.Parser]::ParseInput($content, [ref]$tokens, [ref]$errors) | Out-Null + $errors | Should -BeNullOrEmpty + } +} + +Describe 'Add-PSDepend DependencyType default resolution' { + It 'Infers GitHub from an owner/repo-shaped Name' { + $dir = Initialize-AddProject -Name 'infer-github' + + $file = Add-PSDepend -Path $dir -Name 'RamblingCookieMonster/PowerShell' -Version main -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "DependencyType = 'GitHub'" + } + + It 'Infers Git from a multi-segment path-shaped Name' { + $dir = Initialize-AddProject -Name 'infer-git' + + $file = Add-PSDepend -Path $dir -Name 'gitlab.example.com/org/some' -Version main -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "DependencyType = 'Git'" + } + + It "Falls back to the target file's PSDependOptions.DependencyType" { + $dir = Initialize-AddProject -Name 'psdependoptions-default' -Body @' +@{ + PSDependOptions = @{ + DependencyType = 'FakeResolver' + } +} +'@ + + $file = Add-PSDepend -Path $dir -Name App -PSDependTypePath $script:MapPath -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'App' = @\{" + $content | Should -Match "DependencyType = 'FakeResolver'" + } + + It 'An explicit -DependencyType overrides PSDependOptions and name-pattern inference' { + $dir = Initialize-AddProject -Name 'explicit-wins' -Body @' +@{ + PSDependOptions = @{ + DependencyType = 'FakeResolver' + } +} +'@ + + $file = Add-PSDepend -Path $dir -Name 'owner/repo' -DependencyType Noop -PSDependTypePath $script:MapPath -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "DependencyType = 'Noop'" + } +} + +Describe 'Add-PSDepend collisions' { + It 'Throws when the dependency already exists, without -Force' { + $dir = Initialize-AddProject -Name 'collision' -Body "@{`r`n psake = 'latest'`r`n}`r`n" + + { Add-PSDepend -Path $dir -Name psake -Version '4.9.1' -NoLock } | + Should -Throw -ExpectedMessage "*'psake' already exists*-Force*" + } + + It 'Fully replaces the existing entry with -Force, dropping fields not given again' { + $dir = Initialize-AddProject -Name 'force-replace' -Body @' +@{ + Pester = @{ + DependencyType = 'PSGalleryModule' + Version = '4.0.0' + Target = 'CurrentUser' + } +} +'@ + + $file = Add-PSDepend -Path $dir -Name Pester -Version '5.9.0' -Force -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "'Pester' = '5\.9\.0'" + $content | Should -Not -Match 'Target' + $content | Should -Not -Match '4\.0\.0' + } + + It "Treats 'PSDependOptions' as a reserved name" { + $dir = Initialize-AddProject -Name 'reserved' + + { Add-PSDepend -Path $dir -Name PSDependOptions -NoLock } | Should -Throw -ExpectedMessage '*reserved*' + } +} + +Describe 'Add-PSDepend preserves existing content' { + It 'Leaves comments and other entries untouched when appending' { + $dir = Initialize-AddProject -Name 'preserve' -Body @' +@{ + # Keep this comment + psake = 'latest' # inline comment +} +'@ + + $file = Add-PSDepend -Path $dir -Name Pester -Version '5.9.0' -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match '# Keep this comment' + $content | Should -Match "psake = 'latest' # inline comment" + $content | Should -Match "'Pester' = '5\.9\.0'" + } + + It 'Preserves a blank line before the closing brace instead of trimming it' { + $dir = Initialize-AddProject -Name 'blank-line' -Body "@{`r`n psake = 'latest'`r`n`r`n}`r`n" + + $file = Add-PSDepend -Path $dir -Name Pester -Version '5.9.0' -NoLock -PassThru + $content = Get-Content -LiteralPath $file -Raw + + $content | Should -Match "psake = 'latest'`r`n`r`n 'Pester'" + } + + It 'Preserves LF-only line endings instead of introducing CRLF' { + $dir = Initialize-AddProject -Name 'lf-only' + $target = Join-Path $dir 'requirements.psd1' + [System.IO.File]::WriteAllText($target, "@{`n psake = 'latest'`n}`n", [System.Text.UTF8Encoding]::new($false)) + + $null = Add-PSDepend -Path $target -Name Pester -Version '5.9.0' -NoLock + $content = [System.IO.File]::ReadAllText($target) + + $content | Should -Not -Match "`r`n" + $content | Should -Match "'Pester' = '5\.9\.0'`n" + } + + It 'Preserves an existing UTF-8 BOM' { + $dir = Initialize-AddProject -Name 'utf8-bom' + $target = Join-Path $dir 'requirements.psd1' + [System.IO.File]::WriteAllText($target, "@{`r`n}`r`n", [System.Text.UTF8Encoding]::new($true)) + + $null = Add-PSDepend -Path $target -Name psake -NoLock + $bytes = [System.IO.File]::ReadAllBytes($target) + + , $bytes[0..2] | Should -Be @(, @(0xEF, 0xBB, 0xBF)) + } + + It 'Writes brand-new DependencyFiles without a BOM' { + $dir = Initialize-AddProject -Name 'no-bom' + + $file = Add-PSDepend -Path $dir -Name psake -NoLock -PassThru + $bytes = [System.IO.File]::ReadAllBytes($file) + + , $bytes[0..2] | Should -Not -Be @(, @(0xEF, 0xBB, 0xBF)) + } +} + +Describe 'Add-PSDepend lock interaction' { + It 'Updates requirements.lock.json after a successful add' { + $dir = Initialize-AddProject -Name 'lock-success' + + $null = Add-PSDepend -Path $dir -Name App -DependencyType FakeResolver -PSDependTypePath $script:MapPath -PassThru + $lockPath = Join-Path $dir 'requirements.lock.json' + + Test-Path -LiteralPath $lockPath | Should -BeTrue + $lock = Get-Content -LiteralPath $lockPath -Raw | ConvertFrom-Json + $lock.packages.'FakeResolver::App'.version | Should -Not -BeNullOrEmpty + } + + It 'Does not touch the lock with -NoLock' { + $dir = Initialize-AddProject -Name 'lock-skip' + + $null = Add-PSDepend -Path $dir -Name App -DependencyType FakeResolver -PSDependTypePath $script:MapPath -NoLock + + Test-Path -LiteralPath (Join-Path $dir 'requirements.lock.json') | Should -BeFalse + } + + It 'Rolls back the edit to an existing file when the lock step fails' { + $dir = Initialize-AddProject -Name 'lock-fail-existing' -Body "@{`r`n psake = 'latest'`r`n}`r`n" + $file = Join-Path $dir 'requirements.psd1' + $before = Get-Content -LiteralPath $file -Raw + + { Add-PSDepend -Path $dir -Name DoesNotExist -DependencyType FakeResolver -PSDependTypePath $script:MapPath } | + Should -Throw -ExpectedMessage '*rolled back*' + + (Get-Content -LiteralPath $file -Raw) | Should -BeExactly $before + Test-Path -LiteralPath (Join-Path $dir 'requirements.lock.json') | Should -BeFalse + } + + It 'Removes a brand-new file entirely when the lock step fails' { + $dir = Initialize-AddProject -Name 'lock-fail-new' + $file = Join-Path $dir 'requirements.psd1' + + { Add-PSDepend -Path $dir -Name DoesNotExist -DependencyType FakeResolver -PSDependTypePath $script:MapPath } | + Should -Throw -ExpectedMessage '*rolled back*' + + Test-Path -LiteralPath $file | Should -BeFalse + } +} + +Describe 'Add-PSDepend WhatIf and PassThru' { + It 'Makes no changes with -WhatIf' { + $dir = Initialize-AddProject -Name 'whatif' + + $null = Add-PSDepend -Path $dir -Name psake -WhatIf + + Test-Path -LiteralPath (Join-Path $dir 'requirements.psd1') | Should -BeFalse + } + + It 'Returns nothing without -PassThru' { + $dir = Initialize-AddProject -Name 'no-passthru' + + $result = Add-PSDepend -Path $dir -Name psake -NoLock + + $result | Should -BeNullOrEmpty + } +} + +Describe 'Add-PSDepend unknown DependencyType' { + It 'Throws a helpful error listing known types' { + $dir = Initialize-AddProject -Name 'unknown-type' + + { Add-PSDepend -Path $dir -Name psake -DependencyType NoSuchType -PSDependTypePath $script:MapPath -NoLock } | + Should -Throw -ExpectedMessage '*NoSuchType*not defined*FakeResolver*' + } +} diff --git a/Tests/DependFiles/psgallerymodule.prerelease.missingparam.depend.psd1 b/Tests/DependFiles/psgallerymodule.prerelease.missingparam.depend.psd1 new file mode 100644 index 0000000..596c5ed --- /dev/null +++ b/Tests/DependFiles/psgallerymodule.prerelease.missingparam.depend.psd1 @@ -0,0 +1,5 @@ +@{ + 'imaginary' = @{ + Version = '0.7.0-beta1' + } +} diff --git a/Tests/DependFiles/psgallerymodule.prerelease.nowarning.depend.psd1 b/Tests/DependFiles/psgallerymodule.prerelease.nowarning.depend.psd1 new file mode 100644 index 0000000..50b3bc6 --- /dev/null +++ b/Tests/DependFiles/psgallerymodule.prerelease.nowarning.depend.psd1 @@ -0,0 +1,8 @@ +@{ + 'imaginary' = @{ + Version = '0.7.0-beta1' + Parameters = @{ + AllowPrerelease = $true + } + } +} diff --git a/Tests/PSModuleGallery.Type.Tests.ps1 b/Tests/PSModuleGallery.Type.Tests.ps1 index b8930ea..a368b25 100644 --- a/Tests/PSModuleGallery.Type.Tests.ps1 +++ b/Tests/PSModuleGallery.Type.Tests.ps1 @@ -476,6 +476,36 @@ Describe "PSModuleGallery Type" -Tag 'Integration' { } } } + + Context 'Prerelease version without AllowPrerelease' { + BeforeAll { + Mock Get-PSRepository { return $true } -ModuleName PSDepend + Mock Install-Module {} -ModuleName PSDepend + Mock Write-Warning {} -ModuleName PSDepend + } + + It 'Warns when Version looks like a prerelease but AllowPrerelease is not specified' { + Invoke-PSDepend @Verbose -Path "$TestDepends\psgallerymodule.prerelease.missingparam.depend.psd1" -Force -ErrorAction Stop + Should -Invoke Write-Warning -Times 1 -Exactly -Scope Context -ModuleName PSDepend -ParameterFilter { + $Message -like '*prerelease*' + } + } + } + + Context 'Prerelease version with AllowPrerelease' { + BeforeAll { + Mock Get-PSRepository { return $true } -ModuleName PSDepend + Mock Install-Module {} -ModuleName PSDepend + Mock Write-Warning {} -ModuleName PSDepend + } + + It 'Does not warn when AllowPrerelease is specified' { + Invoke-PSDepend @Verbose -Path "$TestDepends\psgallerymodule.prerelease.nowarning.depend.psd1" -Force -ErrorAction Stop + Should -Invoke Write-Warning -Times 0 -Exactly -Scope Context -ModuleName PSDepend -ParameterFilter { + $Message -like '*prerelease*' + } + } + } } Describe "Git Type PS$PSVersion" -Skip:$nonWindows { diff --git a/Tests/Test-PSDependPrereleaseVersion.Tests.ps1 b/Tests/Test-PSDependPrereleaseVersion.Tests.ps1 new file mode 100644 index 0000000..86820cb --- /dev/null +++ b/Tests/Test-PSDependPrereleaseVersion.Tests.ps1 @@ -0,0 +1,111 @@ +#requires -Module @{ ModuleName = 'Pester'; ModuleVersion = '5.0.0' } + +BeforeAll { + if (-not $env:BHProjectPath) { + & "$PSScriptRoot\..\build.ps1" -Task 'Build' + } + Remove-Module $env:BHProjectName -ErrorAction SilentlyContinue + Import-Module (Join-Path $env:BHProjectPath $env:BHProjectName) -Force +} + +Describe 'Test-PSDependPrereleaseVersion' { + + Context 'Realistic prerelease versions (should warn)' { + + It 'Detects a single alphanumeric prerelease label ()' -TestCases @( + @{ Version = '2.5.0-alpha' } + @{ Version = '0.7.0-beta1' } + @{ Version = '1.2.3-rc1' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $true + } + + It 'Detects a dotted SemVer 2.0 prerelease label even though PowerShellGet module tags forbid dots ()' -TestCases @( + @{ Version = '1.0.0-beta.1' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $true + } + + It 'Detects a prerelease label with SemVer build metadata ()' -TestCases @( + @{ Version = '1.0.0-beta+build.5' } + @{ Version = '1.0.0-beta1+001' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $true + } + + It 'Detects a leading-zero numeric prerelease identifier even though SemVer forbids it ()' -TestCases @( + @{ Version = '1.0.0-01' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $true + } + + It 'Detects a 4-part version with a prerelease suffix, even though no real Gallery module can have one ()' -TestCases @( + @{ Version = '1.2.3.4-beta1' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $true + } + } + + Context 'Plain (non-prerelease) versions (should not warn)' { + + It 'Does not flag a version with no dash suffix ()' -TestCases @( + @{ Version = '1.2' } + @{ Version = '1.2.3' } + @{ Version = '1.2.3.4' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $false + } + } + + Context 'Invalid or out-of-scope version strings (should not warn)' { + + It 'Does not flag a bare single-component version with a dash suffix, since no real ModuleVersion has fewer than two segments ()' -TestCases @( + @{ Version = '5-beta1' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $false + } + + It 'Does not flag non-version strings ()' -TestCases @( + @{ Version = 'latest' } + @{ Version = '' } + @{ Version = $null } + @{ Version = 'not-a-version' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $false + } + + It 'Does not flag NuGet range syntax, since ranges are never passed to this helper ()' -TestCases @( + @{ Version = '[1.0.0,2.0.0)' } + @{ Version = '[2.0,)' } + ) { + InModuleScope PSDepend -Parameters @{ Version = $Version } { + param($Version) + Test-PSDependPrereleaseVersion -Version $Version + } | Should -Be $false + } + } +} diff --git a/docs/PSDependScripts-PackageManagerLockfileSupport.md b/docs/PSDependScripts-PackageManagerLockfileSupport.md new file mode 100644 index 0000000..b8d032b --- /dev/null +++ b/docs/PSDependScripts-PackageManagerLockfileSupport.md @@ -0,0 +1,55 @@ +# PSDependScripts package-manager lockfile support + +## Scope and terms + +This is an audit of the handlers in `PSDepend/PSDependScripts`. A **native +lockfile** here is a manager-maintained record of resolved dependencies that a +frozen/locked operation consumes without re-resolving it. A PSDepend +`Dependency.Version` value is an input pin (or range), not such a file. + +The handler links in the table are the primary source for the command actually +invoked. The numbered links are first-party tool documentation or source. + +| Handler | Manager/tool actually invoked | Native lockfile / locked mode | Relevant behavior and constraints | Evidence | +| --- | --- | --- | --- | --- | +| [Chocolatey](../PSDepend/PSDependScripts/Chocolatey.ps1) | `choco.exe list`, `search`, and `upgrade`; installation passes `--version` only for an explicit PSDepend version. | **No generated resolved lockfile or lock mode documented.** Chocolatey accepts a user-authored `packages.config` manifest, but this handler does not invoke it. | A `packages.config` entry may directly specify `version`; Chocolatey also has installed-package `pin`. Neither records/enforces a resolved transitive graph. This handler's `Version` is therefore a direct pin, not a lock. | [Chocolatey install / `packages.config`][1]; [Chocolatey pin][2] | +| [Npm](../PSDepend/PSDependScripts/Npm.ps1) | Internal `Install-NodeModule` calls `npm install --silent [--global] @`. | **Yes: `package-lock.json`; frozen mode: `npm ci`.** The handler does **not** invoke `npm ci` or pass a lock-specific option. | npm auto-generates/updates `package-lock.json` for tree or manifest changes. `npm ci` requires `package-lock.json` or `npm-shrinkwrap.json`, fails when it conflicts with `package.json`, and never writes either. Thus this handler can update/use a local npm lock during `npm install`, but does not offer frozen lockfile enforcement; global installs are outside a project lock. Reuse tree-shaping flags (for example, `--legacy-peer-deps`) with `npm ci`. | [npm lockfile][3]; [npm CI][4]; [helper source](../PSDepend/Private/Install-NodeModule.ps1) | +| [Nuget](../PSDepend/PSDependScripts/Nuget.ps1) | `nuget install -Source -ExcludeVersion -NonInteractive -OutputDirectory [-Version ]`. | **NuGet supports `packages.lock.json` only for PackageReference project restore; this handler does not use it.** | Native locked restore is `nuget restore -UseLockFile -LockedMode` (or equivalent `dotnet`/MSBuild restore). `nuget install` is a direct package/config download and has no locked-mode option; this handler does not call `restore`, provide a project, or read/write a lockfile. Its `-Version` is a direct pin. | [NuGet PackageReference locking][5]; [NuGet restore][6]; [NuGet install][7] | +| [PSGalleryModule](../PSDepend/PSDependScripts/PSGalleryModule.ps1) | PowerShellGet `Find-Module`, then `Install-Module` or `Save-Module`; exact versions become `-RequiredVersion`. | **No native generated resolved lockfile or locked mode in PowerShellGet v2.** | The handler resolves a PSDepend NuGet-style range itself to the highest matching version, then invokes `-RequiredVersion`; this is a direct selected-version pin. `Install-Module` exposes exact/minimum/maximum version parameters, not a lockfile or locked operation. | [PowerShellGet `Install-Module`][8] | +| [PSGalleryNuget](../PSDepend/PSDependScripts/PSGalleryNuget.ps1) | `nuget install` against the PowerShell Gallery NuGet v2 feed, with `-ExcludeVersion`, `-NonInteractive`, `-OutputDirectory`, and optional exact `-Version`. | **No.** NuGet PackageReference locking is not a lockfile facility for PowerShell Gallery module installation. | The handler resolves a PSDepend range to an exact version before calling direct `nuget install`; it never calls NuGet project `restore` or its lock switches. Feed protocol alone does not change the operation into PackageReference restore. | [NuGet PackageReference locking][5]; [NuGet install][7] | +| [PSResourceGet](../PSDepend/PSDependScripts/PSResourceGet.ps1) | `Find-PSResource`, then `Install-PSResource` or `Save-PSResource`; passes `-Version` for an explicit version/range. | **No native generated resolved lockfile or locked mode documented.** | `Install-PSResource` supports declarative `-RequiredResourceFile` (`.json`/`.psd1`) and version ranges, but this is a requirements input, not a manager-generated resolved lock. The handler does not expose `-RequiredResourceFile`; it resolves from its direct `Name`/`Version` input. The cmdlet also does not install dependent resources from NuGet v3 repositories. | [PSResourceGet `Install-PSResource`][9]; [cmdlet source][10] | +| [Package](../PSDepend/PSDependScripts/Package.ps1) | PackageManagement `Get-Package`, `Find-Package`, and `Install-Package`; provider selected by source or `ProviderName`. | **No uniform PackageManagement lockfile/locked mode.** | The handler passes direct `-RequiredVersion` when given and splats `Dependency.Parameters` into `Install-Package`. Provider-specific behavior must not be represented as PackageManagement-wide support; the documented generic and NuGet/PowerShellGet parameter sets expose version/destination/provider controls, not a common lock operation. | [PackageManagement `Install-Package`][11] | +| [Command](../PSDepend/PSDependScripts/Command.ps1) | Dot-sources a `ScriptBlock` created from `Dependency.Source`. | **N/A — not a package manager.** | Executes arbitrary PowerShell; no dependency resolver or lockfile interface. | [handler source](../PSDepend/PSDependScripts/Command.ps1) | +| [DotnetSdk](../PSDepend/PSDependScripts/DotnetSdk.ps1) | Internal `Install-Dotnet -Channel -Version -InstallDir `. | **N/A — SDK installer, not a package manager.** | The requested version/channel is input to the installer; this handler does not consume or create a lockfile. | [handler source](../PSDepend/PSDependScripts/DotnetSdk.ps1) | +| [FileDownload](../PSDepend/PSDependScripts/FileDownload.ps1) | Internal `Get-WebFile -URL -Path `. | **N/A — file downloader, not a package manager.** | Installs a URL-selected artifact; no lockfile or integrity-record mechanism is invoked. | [handler source](../PSDepend/PSDependScripts/FileDownload.ps1) | +| [FileSystem](../PSDepend/PSDependScripts/FileSystem.ps1) | `ROBOCOPY.exe` for folders; `Copy-Item` for files. | **N/A — filesystem copier, not a package manager.** | File copies compare SHA-256 source/target content; that runtime comparison is not a lockfile. Folder copies use `/E`, `/XO`, and optionally `/PURGE`. | [handler source](../PSDepend/PSDependScripts/FileSystem.ps1) | +| [Git](../PSDepend/PSDependScripts/Git.ps1) | `git clone`, then `git checkout `. | **N/A — source-control client, not a package manager.** | A commit hash supplied as `Version` can pin a checkout; a branch/tag name can move. Neither is a manager lockfile and the handler has no lockfile operation. | [handler source](../PSDepend/PSDependScripts/Git.ps1) | +| [GitHub](../PSDepend/PSDependScripts/GitHub.ps1) | GitHub tags/zipball REST endpoints, `Invoke-RestMethod`, `Expand-Archive`, `Copy-Item`. | **N/A — archive downloader, not a package manager.** | `Version` chooses a numeric tag or branch archive. The handler does not consume a lockfile or record artifact integrity. | [handler source](../PSDepend/PSDependScripts/GitHub.ps1) | +| [Noop](../PSDepend/PSDependScripts/Noop.ps1) | Emits received variables and environment data. | **N/A — diagnostic handler, not a package manager.** | It performs no dependency installation. | [handler source](../PSDepend/PSDependScripts/Noop.ps1) | +| [Task](../PSDepend/PSDependScripts/Task.ps1) | Dot-sources existing task scripts with supplied parameters. | **N/A — task runner, not a package manager.** | It executes files selected by `Source` or `Target`; there is no resolver or lockfile interface. | [handler source](../PSDepend/PSDependScripts/Task.ps1) | +| [WindowsRSAT](../PSDepend/PSDependScripts/WindowsRSAT.ps1) | `Install-WindowsFeature` on Server or `Add-WindowsCapability -Online` on Workstation. | **N/A — Windows component servicing, not a package manager.** | It maps an RSAT module to a Windows feature/capability and installs the system component; no lockfile is used. | [handler source](../PSDepend/PSDependScripts/WindowsRSAT.ps1) | + +## Consequences for PSDepend + +* Only the **Npm** handler calls a tool with a general-purpose native lockfile, + but it uses mutable `npm install`, not frozen `npm ci`. +* **Nuget** has native project lock support, but neither `Nuget` nor + `PSGalleryNuget` executes the required PackageReference `restore` path. +* Chocolatey's `packages.config`, PowerShellGet's `-RequiredVersion`, + PSResourceGet's `-RequiredResourceFile`, and every handler's PSDepend + `Version` value are declarative requirements or direct pins. They must not be + described as native resolved lockfiles or locked mode. + +## First-party sources + +[1]: https://docs.chocolatey.org/en-us/choco/commands/install/ +[2]: https://docs.chocolatey.org/en-us/choco/commands/pin/ +[3]: https://docs.npmjs.com/cli/v11/configuring-npm/package-lock-json/ +[4]: https://docs.npmjs.com/cli/v11/commands/npm-ci/ +[5]: https://learn.microsoft.com/en-us/nuget/consume-packages/package-references-in-project-files#locking-dependencies +[6]: https://learn.microsoft.com/en-us/nuget/reference/cli-reference/cli-ref-restore +[7]: https://learn.microsoft.com/en-us/nuget/reference/cli-reference/cli-ref-install +[8]: https://learn.microsoft.com/en-us/powershell/module/powershellget/install-module?view=powershellget-2.x +[9]: https://learn.microsoft.com/en-us/powershell/module/microsoft.powershell.psresourceget/install-psresource?view=powershellget-3.x +[10]: https://raw.githubusercontent.com/PowerShell/PSResourceGet/master/src/code/InstallPSResource.cs +[11]: https://learn.microsoft.com/en-us/powershell/module/packagemanagement/install-package?view=powershellget-2.x diff --git a/docs/PSDependScripts-PrereleaseVersionDetection.md b/docs/PSDependScripts-PrereleaseVersionDetection.md new file mode 100644 index 0000000..a3a6019 --- /dev/null +++ b/docs/PSDependScripts-PrereleaseVersionDetection.md @@ -0,0 +1,261 @@ +# Prerelease Version Detection — Research Findings + +Investigation backing the prerelease-version warning added to +`PSDepend/PSDependScripts/PSGalleryModule.ps1`: + +```powershell +$isPrereleaseVersion = $versionRange.Exact -match '^\d+(\.\d+){1,3}-[0-9A-Za-z][0-9A-Za-z\.-]*$' +$allowsPrerelease = $PSBoundParameters.ContainsKey('AllowPrerelease') -and $AllowPrerelease +if ($isPrereleaseVersion -and -not $allowsPrerelease) { + Write-Warning "..." +} +``` + +This document answers three research questions ahead of extracting the regex into a +`PSDepend/Private/` helper. **File placement note:** named +`PSDependScripts-PrereleaseVersionDetection.md` to match the existing flat, +topic-focused naming convention seen in `docs/PSDependScripts-ReviewerChecklist.md` +and `docs/PSDependScripts-PackageManagerLockfileSupport.md` (prefix identifies the +subsystem, suffix identifies the topic; no subfolder). + +--- + +## Q1 — Why `$PSBoundParameters.ContainsKey('AllowPrerelease')` instead of `if ($AllowPrerelease)`? + +### 1. Does PowerShellGet 1.0.0.1 (Windows PowerShell 5.1 inbox) support `-AllowPrerelease`? + +No for the inbox version, yes once PowerShellGet is updated. `-AllowPrerelease` was added +to `Find-Module`/`Install-Module`/`Update-Module`/`Save-Module` in **PowerShellGet 1.6.0**, +as part of the "Prerelease Version Support" feature: + +> "New features … \* Prerelease Version Support (#185) - Implemented prerelease versions +> functionality in PowerShellGet cmdlets. Enables publishing, discovering, and installing +> the prerelease versions of modules and scripts from the PowerShell Gallery." +— PowerShellGet release notes (`PrivateData.PSData.ReleaseNotes` in the module manifest), +section "1.6.0", https://www.powershellgallery.com/packages/PowerShellGet/2.2.5/Content/PowerShellGet.psd1 + +The inbox PowerShellGet shipped with Windows PowerShell 5.1 (WMF 5.1, released 2017) is +**1.0.0.1**, which predates 1.6.0 and therefore has **no `-AllowPrerelease` parameter at +all** on `Install-Module`/`Find-Module`. This is corroborated by the conceptual doc for +the feature, which frames `-AllowPrerelease` as something introduced "Starting with +version 1.6.0": + +> "Starting with version 1.6.0, PowerShellGet and the PowerShell Gallery provide support +> for tagging versions greater than 1.0.0 as a prerelease. … Dealing with prerelease +> packages using PowerShellGet Find-Module, Install-Module, Update-Module, and +> Save-Module commands requires adding the `-AllowPrerelease` flag." +— https://learn.microsoft.com/en-us/powershell/gallery/concepts/module-prerelease-support + +So any machine still on inbox PowerShellGet 1.0.0.1 (a real, supported scenario — many +CI/air-gapped Windows PowerShell 5.1 hosts never update PowerShellGet) will have an +`Install-Module` that **does not have** an `-AllowPrerelease` parameter. + +### 2. Does explicit `-AllowPrerelease:$false` behave differently from omission? + +Evidence found does **not** show a documented behavioral difference for `Install-Module` +itself between `-AllowPrerelease:$false` and omitting the parameter — the conceptual doc +and `Install-Module`/`Uninstall-Module` reference pages describe `-AllowPrerelease` as a +simple opt-in flag; there is no documented "tri-state" semantics (e.g. "unset" vs. +"explicitly false") for `Install-Module` or `Find-Module`. The one place a genuine +tri-state distinction is documented is `Uninstall-Module`, which treats "no +`-RequiredVersion`" as "remove latest" regardless of `-AllowPrerelease`, but that's +orthogonal to `$params.Add('AllowPrerelease', ...)` for `Install-Module`. +(https://learn.microsoft.com/en-us/powershell/gallery/concepts/module-prerelease-support, +"Uninstall-Module" section.) + +### 3. Verdict + +The `ContainsKey` guard is **still doing useful, non-redundant work today**, but for a +different reason than parameter-set compatibility alone — **it predates and is partially +subsumed by, but not fully redundant with, the generic filter**: + +- The **generic filter** (`$availableParameters = (Get-Command "Install-Module").Parameters`, + added in commit `06f6be1`, 2018-05-03) runs *after* `$Params` is built and strips any key + whose name `Install-Module` doesn't recognize — this alone is sufficient to stop PSDepend + from ever passing a literal `-AllowPrerelease` switch to an inbox 1.0.0.1 `Install-Module` + (which has no such parameter), confirmed by reading the current script + (`PSDepend/PSDependScripts/PSGalleryModule.ps1:311-318`). +- However, commit `072d776` (2020-07-04, "Only pass AcceptLicense and AllowPrerelease + params if specified in PSGalleryModule") changed **both** `AllowPrerelease` **and** + `AcceptLicense` from unconditional hashtable members (`AllowPrerelease = $AllowPrerelease`) + to conditional ones. Before that commit, `$params` *always* contained + `AllowPrerelease = $false` (the then-default) whenever `Install-Module` *did* support the + parameter (e.g. any post-1.6.0 PowerShellGet). On such hosts, the generic filter does + **not** remove the key (the parameter exists), so PSDepend was *always* passing + `-AllowPrerelease:$false` explicitly to every `Install-Module` call, forcing + `AcceptLicense` to `$true` by default too (silently auto-accepting license terms the + user never opted into). The `ContainsKey` guard fixes that: it ensures PSDepend only + passes these two parameters when the user actually set them in `Parameters = @{ }`, + letting `Install-Module`'s own default behavior apply otherwise. This is stated + verbatim in the commit's intent (commit message + diff removing the hardcoded + `AcceptLicense = $True` default), https://github.com//commit/072d77622902f25397e71efc563051b42402f62 + (local: `git show 072d776`). +- So: **for the narrow question of "does `Install-Module` accept the parameter at all," + the generic filter alone is sufficient and the `ContainsKey` guard is redundant.** But + the guard's *actual*, still-relevant purpose is different: it prevents PSDepend from + silently overriding `Install-Module`'s own defaults for `AllowPrerelease` and + `AcceptLicense` (most importantly, forcing license acceptance) on any host where the + parameters exist. That purpose is **not** covered by the generic filter and remains + necessary. + +**Confidence: high** for the historical motivation (directly confirmed by the commit +diff and message) and for "generic filter alone would handle the +parameter-doesn't-exist case"; **high** for "the guard still matters" because the +`AcceptLicense` auto-$true regression is a real, observable behavior change that the +generic filter cannot prevent (it only filters by key name, not value). + +--- + +## Q2 — Is `[System.Management.Automation.SemanticVersion]` safe to depend on for PS 5.1? + +### 1. Which PowerShell version introduced it, and is it present/consistent in Windows PowerShell 5.1 inbox? + +**`SemanticVersion` is NOT available by default in Windows PowerShell 5.1.** The type was +introduced as part of PowerShell Core's engine (`System.Management.Automation.dll` +shipped with PowerShell 6+), not the Windows PowerShell 5.1 inbox assembly. Confirmed by +PowerShellGet-adjacent history and, directly, by Microsoft's own PSScriptAnalyzer +`UseCompatibleTypes` rule documentation, which uses exactly this type as its worked +example of a type *missing* on 5.1: + +> "`System.Management.Automation.SemanticVersion` isn't available by default in Windows +> PowerShell 5.1, so the rule flags this type usage for that target profile." +> +> ```powershell +> $version = [System.Management.Automation.SemanticVersion]'1.2.3' +> ``` +> — flagged noncompliant against target profile +> `win-48_x64_10.0.17763.0_5.1.17763.316_x64_4.0.30319.42000_framework` (Windows 10 Pro, +> PowerShell 5.1) +— https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/rules/usecompatibletypes + +This profile is generated by reflecting over the real, installed `System.Management.Automation.dll` +for that platform, so it is a direct, primary statement about the type's absence in the +inbox 5.1 assembly (not merely "untested" — PSScriptAnalyzer's compatibility checker +builds per-platform JSON manifests of every type actually present). + +This also matches the iterative way the type was built out in the PowerShell/PowerShell +engine itself, long after Windows PowerShell 5.1 (WMF 5.1, 2017) shipped: + +- PS 6.0-era: "Relax the PowerShell `SemanticVersion` constructors to not require + 'minor' and 'patch' portions…" (#3696); "Enable comparison between a `SemanticVersion` + instance and a `Version` instance…" (#3793) — https://github.com/PowerShell/PowerShell/blob/master/CHANGELOG/6.0.md +- PS 6.1: `semver` added as a **type accelerator** for + `System.Management.Automation.SemanticVersion` (#4142) — + https://github.com/PowerShell/PowerShell/blob/master/CHANGELOG/6.0.md (6.1.0 section) +- PS 6.2: "Make `SemanticVersion` compatible with `SemVer 2.0`" (#5037) — same file, + 6.2.0 section. + +**Practical consequence for PSDepend:** `PSDepend/Private/Test-PSDependExactVersion.ps1` +and `Compare-Version.ps1` call `[System.Management.Automation.SemanticVersion]::TryParse()` +unconditionally, with no `try`/`catch` around type resolution. On genuine Windows +PowerShell 5.1 (not PowerShell 7+ running on Windows), referencing this type throws a +`RuntimeException` ("Unable to find type…") rather than returning `$false` — this is a +real compatibility risk for the module's stated 5.1 support, independent of the +prerelease-detection feature. (This is a finding to flag for whoever owns those Private +helpers; it is out of scope for the regex extraction task itself, and no PowerShell +source file was modified as part of this investigation per the assignment.) + +### 2. `SemanticVersion`'s parsing grammar (from source) + +Source: `src/System.Management.Automation/engine/PSVersionInfo.cs` in the +PowerShell/PowerShell repo (the `SemanticVersion` struct lives in this file, not a +separate `SemanticVersion.cs`), lines 337-340: + +```csharp +private const string VersionSansRegEx = @"^(?\d+)(\.(?\d+))?(\.(?\d+))?$"; +private const string LabelRegEx = @"^(?(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$"; +private const string LabelUnitRegEx = @"^((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)$"; +private const string BuildUnitRegEx = @"^([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)$"; +``` +— https://github.com/PowerShell/PowerShell/blob/master/src/System.Management.Automation/engine/PSVersionInfo.cs#L337-L340 + +Documented grammar derived from the regexes above: + +- **Numeric component count:** `VersionSansRegEx` requires **1 to 3** numeric + dot-separated components (`major` required; `minor`, `patch` optional). **4-part + versions (`1.2.3.4`) are rejected outright** — `SemanticVersion` has no 4th + "revision" component, unlike `System.Version`. +- **Prerelease label (`LabelRegEx`/`LabelUnitRegEx`):** one or more + dot-separated identifiers (`(?:\.(?:...))*` — dots **are** allowed, i.e. SemVer 2.0 + style `1.0.0-beta.1` is accepted), where each identifier is either all-numeric with no + leading zero (`0` or `[1-9]\d*`) or alphanumeric/hyphen (`\d*[a-zA-Z-][0-9a-zA-Z-]*`, + i.e. must contain at least one non-digit). +- **Build metadata (`+...`):** supported via the same `LabelRegEx` + (`(?:\+(?[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?`) — one or more + dot-separated alphanumeric/hyphen identifiers after a literal `+`. + **Caveat:** a long-standing engine bug (fixed later, see below) rejected build + metadata when no prerelease label was present, e.g. `1.0.0+asdf` originally threw + `FormatException`, while `1.0.0-beta+asdf` worked — confirmed via + https://github.com/PowerShell/PowerShell/issues/14605 (opened 2021-01-13 against + PS 7.1.0, closed as fixed; a later `pwsh` 7.3.0 screenshot in the same thread shows it + parsing correctly). Consumers targeting older PS 7.0/7.1 should not assume + `1.0.0+build` parses. +- **What it rejects:** anything not matching `VersionSansRegEx` for the numeric part + (so no 4-part versions, no non-digit characters in major/minor/patch, no leading `v`), + and prerelease/build labels containing characters outside `[0-9A-Za-z-]` per identifier + (dots are the only other allowed separator, and only *between* identifiers). + +### Honoring PSDepend's existing repo convention + +`docs/PSDependScripts-ReviewerChecklist.md`'s "Version comparison (for installers)" +section already documents the `[System.Version]` → `[SemanticVersion]` → string-equality +pattern used by `Test-PSDependExactVersion.ps1` / `Compare-Version.ps1`; this document +does not restate that guidance, only supplements it with the PS-5.1-availability caveat +above, which that checklist does not currently cover. + +--- + +## Q3 — Validated test matrix of version strings + +| # | Version string | (a) Plausible exact `Version` PSDepend could receive? | (b) `SemanticVersion.TryParse` accepts? | (c) Carries a prerelease label a human would expect a warning for? | +|---|---|---|---|---| +| 1 | `2.5.0-alpha` | **Yes.** Canonical real `Publish-Module -Prerelease alpha` shape: 3-part `ModuleVersion` + hyphen + alphanumeric prerelease tag. Matches the documented module-prerelease requirement ("Prerelease string may only be specified when the ModuleVersion is 3 segments… hyphen is the delimiter… [0-9A-Za-z-]"), https://learn.microsoft.com/en-us/powershell/gallery/concepts/module-prerelease-support#identifying-a-module-version-as-a-prerelease. | Yes — 3 numeric components + single alphanumeric prerelease identifier matches `VersionSansRegEx`/`LabelUnitRegEx`. | Yes. | +| 2 | `0.7.0-beta1` | **Yes.** Same shape, digit appended directly to the label (common convention: `-alpha1`, `-BETA`, `-update20171020` per the same doc's examples). This is the string used in PSGalleryModule.ps1's own comment-based help example. | Yes. | Yes. | +| 3 | `1.0.0-beta.1` | **SemVer-2.0-valid, but PSGallery-module-invalid.** The module-prerelease doc explicitly forbids dots in the module Prerelease field: "Prerelease string **must not** contain either period or + [.+], which are allowed in SemVer 2.0. Only SemVer v1.0.0 prerelease strings are supported." A real `Publish-Module -Prerelease` call with a dotted tag is rejected at publish time, so this string should not appear as a module's exact version from the Gallery. It *can* still appear in `Dependency.Version` if a user hand-writes a PSDepend file expecting NuGet/SemVer 2.0 semantics. | Yes — `LabelRegEx`/`LabelUnitRegEx` explicitly allow dot-separated identifiers (`(?:\.(?:...))*`). | Yes (SemVer sense), even though PowerShellGet modules can't legally publish it. | +| 4 | `1.0.0-beta+build.5` | NuGet/SemVer-2.0 shape with build metadata; not a valid PowerShellGet module Prerelease tag (`+` forbidden per the doc quoted in row 3) but valid generic SemVer 2.0.0, https://semver.org/#spec-item-10, and valid NuGet syntax, https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#semantic-versioning-200 ("package version to be SemVer v2.0.0 specific if … The version has build-metadata"). | Yes (prerelease label present, so the build-metadata parsing bug in row below doesn't apply) — matches `LabelRegEx`. | Yes — still carries a prerelease label (`beta`); build metadata is cosmetic/ignored for precedence per SemVer §11. | +| 5 | `5-beta1` | Edge case: single numeric major component plus dash. **`SemanticVersion` accepts it** — `VersionSansRegEx`'s minor/patch are optional (`(?:\.(?\d+))?(?:\.(?\d+))?`), so `major=5` alone satisfies the numeric part, and `beta1` matches `LabelUnitRegEx`. **`System.Version` rejects it outright** — `System.Version` requires at minimum `Major.Minor` (two components) and has no concept of a prerelease suffix at all; a bare `5` or `5-beta1` fails `System.Version.TryParse`, https://learn.microsoft.com/en-us/dotnet/api/system.version.tryparse. Not a realistic PSGallery module version (ModuleVersion must have ≥2 segments per PowerShell module manifest rules), but realistic as a raw NuGet/generic SemVer tag. | Yes. | Yes (if encountered). | +| 6 | `1.2` | 2-part plain version — valid `ModuleVersion`/`RequiredVersion` shape; common for simple modules. | Yes (`minor` present, `patch` omitted). | No — no `-` present, so the PSGalleryModule.ps1 regex and `LabelRegEx`'s optional prerelease group both see no label. | +| 7 | `1.2.3` | 3-part plain version — the canonical PowerShellGet "stable" ModuleVersion shape required *in order to use* the Prerelease field (see row 1). | Yes. | No. | +| 8 | `1.2.3.4` | 4-part plain version — valid `[System.Version]`/`.psd1 ModuleVersion` shape (PowerShell module manifests allow up to 4 segments, e.g. many modules use `Major.Minor.Build.Revision`). | **No** — `VersionSansRegEx` caps at 3 numeric components; `SemanticVersion.TryParse("1.2.3.4")` fails, falling through to the `[System.Version]::TryParse` branch in `Test-PSDependExactVersion.ps1`. `System.Version` does accept up to 4 components, https://learn.microsoft.com/en-us/dotnet/api/system.version#remarks. | No. | +| 9 | `1.2.3.4-beta1` | 4-part version with a dash suffix — **not a legal shape for either type.** PowerShellGet's own Prerelease feature requires exactly 3 segments before the hyphen (row 1's doc quote: "may only be specified when the ModuleVersion is 3 segments"), so the Gallery itself cannot produce this as an exact `RequiredVersion`. `SemanticVersion.TryParse` fails (4 numeric components exceeds `VersionSansRegEx`'s 3-component cap, and `VersionSansRegEx` doesn't even have a dash/label group — that's handled by a separate pass in `SemanticVersion.TryParse`, but the leading numeric part still fails first). `System.Version.TryParse` also fails (it has no prerelease-suffix syntax at all — any non-digit/non-dot character after the numeric parts is invalid, https://learn.microsoft.com/en-us/dotnet/api/system.version.tryparse). PSDepend's existing regex (`^\d+(\.\d+){1,3}-[0-9A-Za-z][0-9A-Za-z\.-]*$`) *does* match this string (1-4 numeric groups then a dash) even though neither parser can confirm it's a real version — a gap worth noting if the regex is reused as a general "exact version" validity check rather than purely a prerelease heuristic. | No (as noted, matches PSDepend's current ad hoc regex but not SemanticVersion). | Debatable/edge case — looks like a prerelease marker but is not a producible real Gallery version; a human maintaining a lockfile by hand *could* still type this expecting a warning. | +| 10 | `[1.0.0,2.0.0)` | NuGet range syntax (mixed inclusive-min/exclusive-max), explicitly valid per the NuGet versioning doc's range table (`[1.0,2.0)` → "Mixed inclusive minimum and exclusive maximum version"), https://learn.microsoft.com/en-us/nuget/concepts/package-versioning#version-ranges. This is a **range**, not an exact version — `$versionRange.IsExact` will be `$false` in `PSGalleryModule.ps1`, so the prerelease-detection branch (`if ($versionRange.IsExact) { ... }`) never runs for it; `ConvertFrom-VersionRange` handles it before the regex is ever evaluated. | N/A — never reaches `SemanticVersion.TryParse` or the prerelease regex in the current code path (range, not exact version). | N/A. | +| 11 | `latest` | The sentinel PSDepend/PowerShellGet use for "no explicit version" — `PSGalleryModule.ps1` special-cases `$Version -and $Version -ne 'latest'` before even calling `ConvertFrom-VersionRange`, so it never reaches the prerelease check. Confirmed in the current script: `if ($Version -and $Version -ne 'latest') { $versionRange = ConvertFrom-VersionRange ... }`. | N/A — literal non-numeric string, would fail both parsers if tried, but the code never tries. | N/A — but `PSGalleryModule.ps1` has separate, pre-existing handling: "If specified along with version `latest`, a prerelease will be selected if it is the latest version" (comment-based help), so prerelease-ness of "latest" is resolved remotely via `-AllowPrerelease`, not via this regex. | +| 12 | `1.0.0-0` | Realistic edge case not in the original list: all-numeric prerelease identifier with a single digit. SemVer forbids leading zeros in numeric identifiers but allows a bare `0`. Valid NuGet/SemVer 2.0 prerelease (`0` matches SemVer's numeric-identifier grammar, https://semver.org/#spec-item-9), and valid PowerShellGet-module Prerelease tag (digits are within `[0-9A-Za-z-]`). | Yes — `LabelUnitRegEx`'s numeric alternative is `(?:0|[1-9]\d*|...)`, and a bare `0` is explicitly allowed as the first alternative. | Yes. | +| 13 | `1.0.0-01` | Realistic edge case: **leading-zero numeric prerelease identifier.** SemVer 2.0 spec explicitly disallows this ("Numeric identifiers MUST NOT include leading zeroes", https://semver.org/#spec-item-9). | **Rejected** by `SemanticVersion` — `LabelUnitRegEx`'s numeric branch is `0|[1-9]\d*`, which cannot match `01` (not `0` exactly, and `[1-9]\d*` requires a non-zero leading digit); the alphanumeric branch `\d*[a-zA-Z-][0-9a-zA-Z-]*` also can't match `01` (no letter/hyphen present). So `TryParse` fails, falling through to `System.Version` (which also fails, since `-01` isn't valid `System.Version` syntax) — `Test-PSDependExactVersion` would return `$false` for `1.0.0-01` even though it visually looks like a prerelease version a human typed. PSDepend's own regex (`[0-9A-Za-z\.-]*` after the first char) **does** still match it and would warn, which is arguably the more useful behavior here since it's "SemVer-shaped but technically invalid." | Yes, in the loose sense that a human reading `1.0.0-01` would expect prerelease warning treatment, even though strict SemVer/`SemanticVersion` reject it. | + +### Summary of PSDepend-regex vs. `SemanticVersion` divergence + +The current ad hoc regex in `PSGalleryModule.ps1` +(`'^\d+(\.\d+){1,3}-[0-9A-Za-z][0-9A-Za-z\.-]*$'`) is **looser** than `SemanticVersion` +in two ways relevant to extraction into a shared helper: + +1. It accepts **4 numeric components** (`{1,3}` repetitions of `\.\d+` after the first + `\d+` = up to 4 total), matching `System.Version`'s range, not `SemanticVersion`'s + 3-component cap (row 8/9 above). +2. It accepts **any** label after the first alphanumeric character + (`[0-9A-Za-z\.-]*`, unanchored to SemVer's per-identifier grammar), so it does **not** + reject SemVer-invalid shapes like leading-zero numeric identifiers (`1.0.0-01`, row + 13) or empty dot-separated segments (`1.0.0-beta..1`) that `SemanticVersion` would + reject. This is arguably *correct* for a "does this look like a prerelease, should we + warn" heuristic (false positives here are harmless — just an extra warning), but it is + **not** a drop-in replacement for `[SemanticVersion]::TryParse` as a general version + *validity* check, and the two should not be conflated when the regex is extracted. + +--- + +## Sources consulted + +- https://learn.microsoft.com/en-us/powershell/gallery/concepts/module-prerelease-support (module Prerelease tag format, `-AllowPrerelease` requirement history) +- https://www.powershellgallery.com/packages/PowerShellGet/2.2.5/Content/PowerShellGet.psd1 (PowerShellGet `ReleaseNotes`, version-by-version changelog including 1.6.0 "Prerelease Version Support") +- https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/rules/usecompatibletypes (primary evidence `SemanticVersion` is absent from Windows PowerShell 5.1 inbox) +- https://github.com/PowerShell/PowerShell/blob/master/src/System.Management.Automation/engine/PSVersionInfo.cs#L337-L340 (`SemanticVersion` parsing regexes, source of truth for grammar) +- https://github.com/PowerShell/PowerShell/issues/14605 (build-metadata-without-prerelease-label parsing bug/history) +- https://github.com/PowerShell/PowerShell/blob/master/CHANGELOG/6.0.md (SemanticVersion constructor relaxation, `semver` type accelerator, SemVer 2.0 compatibility work — version-by-version) +- https://semver.org/spec/v2.0.0.html (SemVer 2.0.0 spec, prerelease/build-metadata identifier grammar, §9–§11) +- https://learn.microsoft.com/en-us/nuget/concepts/package-versioning (NuGet version ranges table, SemVer 2.0.0 support notes) +- https://learn.microsoft.com/en-us/dotnet/api/system.version (`System.Version`/`TryParse` grammar — 2 to 4 numeric components, no prerelease suffix support) +- https://github.com/PowerShell/PowerShellGetv2/issues/607 (dup of #561; prerelease-string validation character-set discussion) +- Local repo: `git show 072d776` (`PSDepend/PSDependScripts/PSGalleryModule.ps1`) — the `ContainsKey('AllowPrerelease')` guard's introducing commit and diff +- Local repo: `PSDepend/PSDependScripts/PSGalleryModule.ps1` (current source, lines ~150-350) — generic parameter filter loop, `ConvertFrom-VersionRange`/`IsExact` gating, `latest` short-circuit +- Local repo: `PSDepend/Private/Test-PSDependExactVersion.ps1` — existing `SemanticVersion` → `System.Version` fallback pattern +- Local repo: `docs/PSDependScripts-ReviewerChecklist.md` — existing documented version-comparison convention (cited, not restated) diff --git a/docs/adr/0001-add-psdepend-file-writer-and-lock-behavior.md b/docs/adr/0001-add-psdepend-file-writer-and-lock-behavior.md new file mode 100644 index 0000000..b19fd7c --- /dev/null +++ b/docs/adr/0001-add-psdepend-file-writer-and-lock-behavior.md @@ -0,0 +1,21 @@ +# Add-PSDepend writes DependencyFiles via hand-rolled AST insertion, not PoshCode/Metadata, and auto-updates the lock + +Status: accepted + +`Add-PSDepend` (issue #26) needs to add a new entry to a `DependencyFile` (`requirements.psd1`/`*.depend.psd1`) without destroying hand-written formatting or comments elsewhere in the file. The original 2017 issue thread recommended building this on `PoshCode/Metadata`'s `Import-Metadata`/`Export-Metadata`. On inspection, `Export-Metadata` fully re-serializes the entire file through `ConvertTo-Metadata` on every call — it reformats *every* existing entry, not just the new one, so comments are lost repo-wide rather than preserved for untouched entries. `Update-Metadata` avoids the full rewrite but its own doc comment states it cannot create new keys, only overwrite an existing uncommented value — unusable for appending a dependency. + +We decided `Add-PSDepend` parses the target file with the PowerShell AST to locate the insertion point and splices in only the new entry's text as a plain append before the closing brace, leaving the rest of the file byte-for-byte untouched, with no dependency on `PoshCode/Metadata`. New entries are written as a terse string (`'Name' = 'Version'`) when only Name and Version were given, or a full hashtable otherwise; the psd1 key is always the bare `Name` (not the `DependencyType::Name` disambiguation form), so adding a same-named dependency of a different type is treated as a collision like any other and requires `-Force`. + +We also decided `Add-PSDepend` auto-runs `Update-PSDependLock` after a successful write (so the repo is never left in the stale-lock state `Merge-PSDependLock` already detects and errors on), with a `-NoLock` switch to opt out for offline/CI use, and rolls back the file write if the lock step fails. + +## Considered Options + +- **Full rewrite via `Export-Metadata`**: simplest implementation, but reformats the whole file and drops comments on every `Add-PSDepend` call — rejected for destroying user-authored formatting outside the one entry being added. +- **Hybrid**: depend on `PoshCode/Metadata` only for `ConvertTo-Metadata` to render the new entry's value, still splice via AST ourselves — rejected in favor of no new dependency at all, since AST insertion alone is sufficient for the value shapes PSDepend's DependencyFile schema actually uses. +- **Leave the lock alone / warn-only after writing**: keeps `Add-PSDepend` network-free, but reintroduces the staleness problem `Update-PSDependLock` was built to catch — rejected in favor of auto-updating with an explicit opt-out. + +## Consequences + +- No new runtime dependency for PSDepend. +- `Add-PSDepend` performs a network call (dependency resolution) by default, which is surprising for a command whose name suggests "edit a file" — mitigated by `-NoLock` and documented explicitly. +- Files containing comments or custom formatting are only safe from reformatting because insertion is append-only; a future change to support re-sorting or in-place updates of existing entries would need the same AST-preserving discipline, not a drop-in `Export-Metadata` call.