src/ConfigFile.ps1
|
# The config FILE (#164): finding it, reading it, and turning it into the policy one gate run # applies. The I/O half; what a config means is decided in Config.ps1, which stays pure. # # Discovery is what lets a gate in CI, a gate run by hand and a future analyzer rule (#163) share # one source of truth -- an analyzer rule has no way to be handed a -ConfigFile. It is also how a # stray file quietly changes a verdict, so it is fenced three ways: it stops at the repository # root, it never happens outside a repository at all, and the file it found is printed and # recorded in the report, so a number cannot be read without the policy behind it. $script:PSCxConfigFileName = '.pscomplexity.json' function Get-PSCxSearchStart { # The directory discovery starts from for one -Path: the path itself when it is a directory, # otherwise the nearest existing directory above it -- which covers a file, a wildcard and a # path that is not there, the last of which the scan refuses on its own terms. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root ) $current = [System.IO.Path]::IsPathRooted($Path) ? $Path : (Join-Path $Root $Path) $current = [System.IO.Path]::GetFullPath($current) while ($current -and -not (Test-Path -LiteralPath $current -PathType Container)) { $current = Split-Path -Parent $current } return $current } function Get-PSCxRepositoryRoot { # The nearest directory at or above Directory that holds .git -- a directory in a clone, a FILE # in a worktree or a submodule -- or $null outside any repository. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyString()] [string]$Directory) $current = $Directory while ($current) { if (Test-Path -LiteralPath (Join-Path $current '.git')) { return $current } $current = Split-Path -Parent $current } return $null } function Find-PSCxConfigFile { # The config that governs one -Path: the nearest .pscomplexity.json from where the path starts # up to the repository root, or $null. # # $null OUTSIDE a repository, even if a config sits somewhere above. Without a root there is no # place to stop, and the search would reach a home directory, where a file left by another # project would start deciding this one's verdicts. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root ) $start = Get-PSCxSearchStart -Path $Path -Root $Root $repository = Get-PSCxRepositoryRoot -Directory $start if (-not $repository) { return $null } $current = $start while ($current.Length -ge $repository.Length) { $candidate = Join-Path $current $script:PSCxConfigFileName if (Test-Path -LiteralPath $candidate -PathType Leaf) { return $candidate } $current = Split-Path -Parent $current } return $null } function Find-PSCxRunConfigFile { # The ONE config every -Path of this run falls under, or $null when none does. # # Refused when two paths disagree -- one under a config and one under another, or under none. # Applying either to both would gate one tree by a policy nobody wrote for it, and picking by # argument order would make the verdict depend on how the command was typed. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Path, [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $found = @($Path | ForEach-Object { [string](Find-PSCxConfigFile -Path $_ -Root $Root) }) $distinct = [System.Collections.Generic.HashSet[string]]::new([string[]]$found, [System.StringComparer]::FromComparison($PathComparison)) if ($distinct.Count -gt 1) { $named = for ($i = 0; $i -lt $Path.Count; $i++) { "$($Path[$i]) -> $(($found[$i]) ? $found[$i] : 'no config')" } throw ('The paths of this run fall under different configs: ' + ($named -join '; ') + '. Gate them in separate runs, or name the one that applies with -ConfigFile.') } return ($found[0]) ? $found[0] : $null } function Read-PSCxConfig { # A config file, checked and resolved -- or a throw naming the file and every fault in it. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $full = [System.IO.Path]::GetFullPath(([System.IO.Path]::IsPathRooted($Path) ? $Path : (Join-Path $Root $Path))) $document = Read-PSCxDocument -Path $full $faults = @(Get-PSCxConfigFault -Document $document -PathComparison $PathComparison) if ($faults.Count -gt 0) { throw "Cannot use the config $full : $($faults -join '; ')." } return Get-PSCxConfigPolicy -Document $document -Path $full -Root $Root -PathComparison $PathComparison } function Resolve-PSCxGatePolicy { # Everything Test-PSComplexity applies besides the code itself: which config, if any, and the # ceilings, acceptances, excludes and baseline that come out of it and the command line. # # Here rather than in the gate, which is a thin predicate and was one branch from its own # ceiling before a config existed. The two refusals about -UpdateBaseline moved with it, # because whether there is a baseline to update is now a question about the config too. [OutputType([pscustomobject])] [CmdletBinding()] param( # The gate's $PSBoundParameters. [Parameter(Mandatory)] [System.Collections.IDictionary]$Bound, [Parameter(Mandatory)] [string[]]$Path ) # Switches through the indexer, which answers $null for one not given and $false for -X:$false. $noConfig = [bool]$Bound['NoConfig'] if ($Bound.ContainsKey('ConfigFile') -and $noConfig) { throw '-ConfigFile and -NoConfig contradict each other: one names the config to apply and the other says to apply none.' } $root = Get-PSCxWorkingRoot $pathComparison = Get-PSCxRunPathComparison -Root $root # The indexer answers $null for a parameter not given, which [string] makes '' -- no config. $configPath = [string]$Bound['ConfigFile'] if (-not $configPath -and -not $noConfig) { $configPath = Find-PSCxRunConfigFile -Path $Path -Root $root -PathComparison $pathComparison } $config = $null if ($configPath) { $config = Read-PSCxConfig -Path $configPath -Root $root -PathComparison $pathComparison # Said every time, because a config is a policy the command line does not show. Information # rather than a warning: nothing is wrong. Shown unless the caller asked for quiet. $action = $Bound.ContainsKey('InformationAction') ? $InformationPreference : 'Continue' Write-Information "Using config $($config.Display)" -InformationAction $action } $policy = Get-PSCxGatePolicy -Config $config -Bound $Bound # Before the scan, because neither answer depends on it and the scan is the expensive part. $update = [bool]$Bound['UpdateBaseline'] if ($update -and -not $policy.BaselineFile) { throw '-UpdateBaseline needs -BaselineFile, or a baselineFile in the config: there is no default path, because a file the gate reads without being told to is one nobody reviews.' } # An update writes what the run measured, so from a run filtered to changed files it would # silently drop every entry for the rest of the tree (#203). if ($update -and ($Bound.ContainsKey('ChangedFile') -or $Bound.ContainsKey('ChangedDiff'))) { throw '-UpdateBaseline cannot be combined with -ChangedFile or -ChangedDiff: the update records what the run measured, and a run over the changed files would drop every baseline entry for the rest of the tree. Update the baseline from a whole-tree run.' } # Refused rather than ignored: a gate run carrying -AllowNew believes it permitted something, # and it permits nothing without an update to apply it to. if ([bool]$Bound['AllowNew'] -and -not $update) { throw '-AllowNew only applies with -UpdateBaseline: it permits an update to record units the baseline has never seen, and without an update there is nothing for it to permit.' } return $policy } |