src/Config.ps1
|
# The config file (#164): what a .pscomplexity.json may say, and what it means for one gate run. # # Pure. Finding the file and reading it off a disk is ConfigFile.ps1's half, for the reason Policy # and BaselineFile are two files: this half decides, so it is tested without a disk, and every # mutant of it re-runs a suite that needs none. # # Everything a config names that is a PATH is relative to the directory the config sits in, never # to the working directory. The file is committed at one place in a repository and read from # wherever somebody happens to run the gate; a path that meant something different from a # subdirectory would make the same file two policies. # The config FORMAT, versioned on its own like the baseline file, because it changes for its own # reasons: a field changing meaning or going away. A config naming another version is refused, # never read as though it were this one. $script:PSCxConfigSchemaVersion = 1 # The ceiling a unit faces when nothing says otherwise. Here rather than as a parameter default on # Test-PSComplexity, because the default now applies only when NEITHER the command line NOR the # config sets one -- and a parameter default cannot tell "not given" from "given as 15". $script:PSCxDefaultCeiling = 15 # The keys a config may hold at the top level. `$schema` is there for editors, which use it to # validate and complete the file; a `_`-prefixed key is a comment, as in PSMutant's config. Anything # else is refused, because a misspelt key -- maxCognitve -- is otherwise a setting that silently # does nothing, and the one it meant to set keeps its default. $script:PSCxConfigKey = @('schemaVersion', 'maxCyclomatic', 'maxCognitive', 'exclude', 'overrides', 'accept', 'baselineFile') function Get-PSCxConfigKeyFault { # The keys on one config object that are not allowed there, as text. Nothing when all are. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Object, [Parameter(Mandatory)] [string[]]$Allowed, # Where the object sits, for the message: 'the config', 'overrides[2]'. [Parameter(Mandatory)] [string]$Where, # Only the top level takes $schema and comments. Inside an entry a stray key is a typo. [switch]$TopLevel ) foreach ($name in $Object.PSObject.Properties.Name) { if ($name -in $Allowed) { continue } if ($TopLevel -and ($name -eq '$schema' -or $name.StartsWith('_'))) { continue } "$Where has an unknown key '$name' -- the keys it takes are $($Allowed -join ', ')" } } function Get-PSCxCeilingValueFault { # Why one ceiling value cannot be used, as text. Nothing when it can. # # A whole number at or above the metric's floor. JSON's 15.0 arrives as a double and is refused rather than # truncated: a ceiling is a count, and a reader of the file should see the number the gate uses. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Value, [Parameter(Mandatory)] [string]$Where, # 1 for cyclomatic, which every unit scores at least; 0 for cognitive, where a ceiling of # 0 is a real policy -- no branching at all -- as the report schema already allows. [Parameter(Mandatory)] [int]$Minimum ) if (($Value -is [long] -or $Value -is [int]) -and $Value -ge $Minimum) { return } return "$Where must be a whole number of at least $Minimum, got '$Value'" } function Get-PSCxConfigPathFault { # Why one path in a config cannot be used, as text. Nothing when it can. # # Relative to the config's own directory, forward slashes, and inside it. A rooted path would # make the committed file mean something on one machine only; a `..` would let it reach out of # the tree it is committed in; a backslash is an ordinary file-name character on Linux, so a # Windows-spelt path would silently match nothing there. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Value, [Parameter(Mandatory)] [string]$Where, # Exclude patterns take * and ?; an override path or a file name does not, because "the # most specific path wins" needs paths that can be ordered by depth. [switch]$AllowWildcard ) if ($Value -isnot [string] -or -not $Value.Trim()) { return "$Where must be a non-empty string" } if ($Value.Contains('\')) { return "$Where is '$Value' -- write paths with forward slashes, which mean the same on every platform" } # Spelt out rather than asked of IsPathRooted, which answers for THIS platform: C:/x is rooted # on Windows and an ordinary relative name on Linux, and one committed file must mean the same # on both CI legs. if ($Value.StartsWith('/') -or $Value -match '^[A-Za-z]:') { return "$Where is '$Value' -- paths in a config are relative to the config's own directory" } $segments = @(Get-PSCxConfigPathSegment -Path $Value) if ($segments -contains '..') { return "$Where is '$Value' -- a config cannot name a path outside its own directory" } if ($segments.Count -eq 0) { return "$Where is '$Value', which names the config's own directory -- that is every file" } if (-not $AllowWildcard -and ($Value.Contains('*') -or $Value.Contains('?'))) { return "$Where is '$Value' -- wildcards are for exclude, not here" } } function Get-PSCxConfigPathSegment { # A config path's segments, without the empty ones a doubled or trailing slash leaves and # without '.', so './tests/' and 'tests' are the same path. [OutputType([string[]])] [CmdletBinding()] # AllowEmptyString: an override with no path reaches here as '' on its way to being refused. param([Parameter(Mandatory)] [AllowEmptyString()] [string]$Path) return [string[]]@($Path -split '/' | Where-Object { $_ -and $_ -ne '.' }) } function Get-PSCxOverrideFault { # Why one per-path override cannot be used, as text. Nothing when it can. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Override, [Parameter(Mandatory)] [string]$Where ) if ($Override -isnot [System.Management.Automation.PSCustomObject]) { return "$Where must be an object with a path and a ceiling" } Get-PSCxConfigKeyFault -Object $Override -Allowed 'path', 'maxCyclomatic', 'maxCognitive' -Where $Where Get-PSCxConfigPathFault -Value $Override.path -Where "$Where.path" $names = $Override.PSObject.Properties.Name # An override that sets neither ceiling changes nothing, and reads as though it did. if ('maxCyclomatic' -notin $names -and 'maxCognitive' -notin $names) { return "$Where sets neither maxCyclomatic nor maxCognitive, so it changes nothing" } if ('maxCyclomatic' -in $names) { Get-PSCxCeilingValueFault -Value $Override.maxCyclomatic -Where "$Where.maxCyclomatic" -Minimum 1 } if ('maxCognitive' -in $names) { Get-PSCxCeilingValueFault -Value $Override.maxCognitive -Where "$Where.maxCognitive" -Minimum 0 } } function Get-PSCxConfigAcceptFault { # Why one acceptance in a config cannot be used, as text. Nothing when it can. # # Its SHAPE only. Whether it still describes the run -- the unit exists, still breaches, has a # reason -- is the gate's acceptance check, which the command line's -Accept goes through too. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Acceptance, [Parameter(Mandatory)] [string]$Where ) if ($Acceptance -isnot [System.Management.Automation.PSCustomObject]) { return "$Where must be an object with file, unit and reason" } Get-PSCxConfigKeyFault -Object $Acceptance -Allowed 'file', 'unit', 'reason' -Where $Where Get-PSCxConfigPathFault -Value $Acceptance.file -Where "$Where.file" } function Get-PSCxConfigListFault { # Why one list in a config cannot be used, as text, checking each entry. Nothing when it can. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Document, [Parameter(Mandatory)] [ValidateSet('exclude', 'overrides', 'accept')] [string]$Name, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) if ($Name -notin $Document.PSObject.Properties.Name) { return } $list = $Document.$Name if ($list -isnot [array]) { return "$Name must be a list" } $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::FromComparison($PathComparison)) for ($i = 0; $i -lt $list.Count; $i++) { $where = "$Name[$i]" switch ($Name) { 'exclude' { Get-PSCxConfigPathFault -Value $list[$i] -Where $where -AllowWildcard } 'accept' { Get-PSCxConfigAcceptFault -Acceptance $list[$i] -Where $where } 'overrides' { Get-PSCxOverrideFault -Override $list[$i] -Where $where # Two overrides for one path leave "the most specific wins" with no answer. $key = (Get-PSCxConfigPathSegment -Path ([string]$list[$i].path)) -join '/' if ($key -and -not $seen.Add($key)) { "$where names '$key' again -- one override per path" } } } } } function Get-PSCxConfigFault { # Every reason a config document cannot be used, as text. Nothing when it can. # # All of them rather than the first, so fixing a hand-edited file is one round trip. Checked # here rather than by the published schema: Test-Json words its messages differently on every # PowerShell, and on 7.6.7 writes none at all for a fault inside a list (#197). The schema ships # for editors, and a test holds the two to the same answers. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Document, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) # The full type name, never [pscustomobject]: as a type TEST that accelerator means PSObject, # which any value passed down a pipeline is wrapped in, so a bare number passed it. if ($Document -isnot [System.Management.Automation.PSCustomObject]) { return 'a config is a JSON object' } $names = $Document.PSObject.Properties.Name # The version first and alone: under another version every other key may mean something else. if ('schemaVersion' -notin $names) { return "the config has no schemaVersion -- this PSComplexity reads $script:PSCxConfigSchemaVersion" } if ($Document.schemaVersion -ne $script:PSCxConfigSchemaVersion) { return "the config is schemaVersion $($Document.schemaVersion) and this PSComplexity reads $script:PSCxConfigSchemaVersion" } Get-PSCxConfigKeyFault -Object $Document -Allowed $script:PSCxConfigKey -Where 'the config' -TopLevel if ('maxCyclomatic' -in $names) { Get-PSCxCeilingValueFault -Value $Document.maxCyclomatic -Where 'maxCyclomatic' -Minimum 1 } if ('maxCognitive' -in $names) { Get-PSCxCeilingValueFault -Value $Document.maxCognitive -Where 'maxCognitive' -Minimum 0 } foreach ($list in 'exclude', 'overrides', 'accept') { Get-PSCxConfigListFault -Document $Document -Name $list -PathComparison $PathComparison } if ('baselineFile' -in $names) { Get-PSCxConfigPathFault -Value $Document.baselineFile -Where 'baselineFile' } } function ConvertTo-PSCxGlobRegex { # One exclude pattern as a regular expression over a path relative to the config's directory. # # `*` and `?` stay inside one segment, `**` spans any number of them, and a pattern also matches # everything BELOW what it names -- so `tests/fixtures` excludes the directory without needing # `/**`, as .gitignore reads it. [OutputType([regex])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Pattern, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $segments = @(Get-PSCxConfigPathSegment -Path $Pattern) $body = '' for ($i = 0; $i -lt $segments.Count; $i++) { $last = $i -eq $segments.Count - 1 if ($segments[$i] -eq '**') { $body += $last ? '.*' : '(?:[^/]+/)*' continue } $body += [regex]::Escape($segments[$i]).Replace('\*', '[^/]*').Replace('\?', '[^/]') if (-not $last) { $body += '/' } } # Case as the run's file system compares names, the same rule every other path here follows. $options = [System.Text.RegularExpressions.RegexOptions]::CultureInvariant if ($PathComparison -eq [System.StringComparison]::OrdinalIgnoreCase) { $options = $options -bor [System.Text.RegularExpressions.RegexOptions]::IgnoreCase } return [regex]::new("^$body(?:/.*)?$", $options) } function Get-PSCxConfigFull { # A config-relative path as a full path, spelt with this platform's separator. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Directory, [Parameter(Mandatory)] [string]$Path ) $relative = (Get-PSCxConfigPathSegment -Path $Path) -join [System.IO.Path]::DirectorySeparatorChar return [System.IO.Path]::GetFullPath((Join-Path $Directory $relative)) } function Get-PSCxConfigPolicy { # A valid config document, resolved against the directory it sits in. # # Every path becomes a full path, and every acceptance's file becomes the File a record carries # -- relative to the working root -- because that is what the gate matches it against. Only the # keys the file SETS are set here: whether a ceiling came from the file or from nowhere is what # lets the command line override one and not the other. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] $Document, # The full path of the config file itself, for the report and for resolving what it names. [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $directory = Split-Path -Parent $Path $names = $Document.PSObject.Properties.Name $patterns = [string[]]@($Document.exclude | Where-Object { $null -ne $_ }) return [pscustomobject]@{ Path = $Path # How the report and the console name it: as a record names a File, so the two read alike. Display = Get-PSCxRelativePath -Path $Path -Root $Root -PathComparison $PathComparison MaxCyclomatic = ('maxCyclomatic' -in $names) ? [int]$Document.maxCyclomatic : $null MaxCognitive = ('maxCognitive' -in $names) ? [int]$Document.maxCognitive : $null Exclude = [pscustomobject]@{ Base = $directory Pattern = $patterns Regex = [regex[]]@($patterns | ForEach-Object { ConvertTo-PSCxGlobRegex -Pattern $_ -PathComparison $PathComparison }) } Overrides = @($Document.overrides | Where-Object { $null -ne $_ } | ForEach-Object { $o = $_.PSObject.Properties.Name [pscustomobject]@{ Path = (Get-PSCxConfigPathSegment -Path $_.path) -join '/' Full = Get-PSCxConfigFull -Directory $directory -Path $_.path MaxCyclomatic = ('maxCyclomatic' -in $o) ? [int]$_.maxCyclomatic : $null MaxCognitive = ('maxCognitive' -in $o) ? [int]$_.maxCognitive : $null } }) Accept = @($Document.accept | Where-Object { $null -ne $_ } | ForEach-Object { [pscustomobject]@{ File = Get-PSCxRelativePath -Path (Get-PSCxConfigFull -Directory $directory -Path $_.file) -Root $Root -PathComparison $PathComparison Unit = [string]$_.unit Reason = [string]$_.reason } }) BaselineFile = ('baselineFile' -in $names) ? (Get-PSCxConfigFull -Directory $directory -Path $Document.baselineFile) : $null } } function Get-PSCxGatePolicy { # What this gate run applies: the command line where it says something, the config where it # does not, the default where neither does. # # An explicit ceiling wins EVERYWHERE, per-path overrides included. A caller who types # -MaxCognitive 10 means every unit, and an override that quietly kept tests/ at 25 would make # the flag mean "10, except where somebody once wrote otherwise". The two metrics are decided # separately, so -MaxCyclomatic alone leaves the file's cognitive ceilings as they were. [OutputType([pscustomobject])] [CmdletBinding()] param( # The resolved config, or $null when the run has none. [Parameter(Mandatory)] [AllowNull()] $Config, # The gate's $PSBoundParameters: what was given, which is not what has a value. [Parameter(Mandatory)] [System.Collections.IDictionary]$Bound ) $cyclomaticGiven = $Bound.ContainsKey('MaxCyclomatic') $cognitiveGiven = $Bound.ContainsKey('MaxCognitive') $overrides = @(Get-PSCxEffectiveOverride -Override @($Config.Overrides | Where-Object { $null -ne $_ }) ` -CyclomaticGiven $cyclomaticGiven -CognitiveGiven $cognitiveGiven) return [pscustomobject]@{ Config = $Config MaxCyclomatic = $cyclomaticGiven ? [int]$Bound['MaxCyclomatic'] : ($Config.MaxCyclomatic ?? $script:PSCxDefaultCeiling) MaxCognitive = $cognitiveGiven ? [int]$Bound['MaxCognitive'] : ($Config.MaxCognitive ?? $script:PSCxDefaultCeiling) Overrides = $overrides Accept = $Bound.ContainsKey('Accept') ? @($Bound['Accept']) : @($Config.Accept | Where-Object { $null -ne $_ }) BaselineFile = $Bound.ContainsKey('BaselineFile') ? [string]$Bound['BaselineFile'] : [string]$Config.BaselineFile Exclude = $Config.Exclude } } function Get-PSCxEffectiveOverride { # The config's overrides with every metric the command line set taken out, and an override # left setting nothing dropped -- so the report lists only the per-path ceilings that applied. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Override, [Parameter(Mandatory)] [bool]$CyclomaticGiven, [Parameter(Mandatory)] [bool]$CognitiveGiven ) foreach ($o in $Override) { $effective = [pscustomobject]@{ Path = $o.Path Full = $o.Full MaxCyclomatic = $CyclomaticGiven ? $null : $o.MaxCyclomatic MaxCognitive = $CognitiveGiven ? $null : $o.MaxCognitive } if ($null -ne $effective.MaxCyclomatic -or $null -ne $effective.MaxCognitive) { $effective } } } function Get-PSCxFileCeiling { # File -> the ceilings its units face, for every measured file an override reaches. A file no # override reaches is left out, and faces the run's ceilings. # # The most specific override wins, per metric: tests/ at cyclomatic 25 and tests/fixtures/ at # cognitive 30 give a fixture both. Overrides name directories or files and never wildcards, so # "most specific" is "deepest", and two overrides of one depth cannot both reach a file. [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseLiteralInitializerForHashtable', '', Justification = 'Keyed by File, which compares as the run file system compares names (#151); the literal @{} always ignores case.')] [OutputType([hashtable])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Override, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive, [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $map = [hashtable]::new([System.StringComparer]::FromComparison($PathComparison)) # Deepest first, so the first override setting a metric is the one that decides it. $ordered = @($Override | Sort-Object { $_.Full.Length } -Descending) foreach ($file in @($Unit | ForEach-Object { [string]$_.File } | Select-Object -Unique)) { $reaching = @(Get-PSCxReachingOverride -File $file -Override $ordered -Root $Root -PathComparison $PathComparison) if ($reaching.Count -eq 0) { continue } $cyclomatic = @($reaching.MaxCyclomatic | Where-Object { $null -ne $_ }) $cognitive = @($reaching.MaxCognitive | Where-Object { $null -ne $_ }) $map[$file] = [pscustomobject]@{ Cyclomatic = ($cyclomatic.Count -gt 0) ? [int]$cyclomatic[0] : $MaxCyclomatic Cognitive = ($cognitive.Count -gt 0) ? [int]$cognitive[0] : $MaxCognitive } } return $map } function Get-PSCxReachingOverride { # The overrides whose path is this file or a directory above it, in the order given. [OutputType([pscustomobject])] [CmdletBinding()] param( # A record's File: relative to Root with forward slashes, or a full path outside it. [Parameter(Mandatory)] [string]$File, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Override, [Parameter(Mandatory)] [string]$Root, [Parameter(Mandatory)] [System.StringComparison]$PathComparison ) $native = $File.Replace('/', [System.IO.Path]::DirectorySeparatorChar) $full = [System.IO.Path]::IsPathRooted($native) ? $native : [System.IO.Path]::GetFullPath((Join-Path $Root $native)) $Override | Where-Object { $full.Equals($_.Full, $PathComparison) -or $full.StartsWith($_.Full + [System.IO.Path]::DirectorySeparatorChar, $PathComparison) } } |