src/Policy.ps1
|
# Policy: which units are excused from the ceilings, and on what terms. # # Two mechanisms, deliberately different in kind: # # An ACCEPTANCE is a decision. It carries a written argument, excuses the unit outright, and # fails the run when it stops describing anything -- the unit was renamed away, or came back # under the ceilings and nobody deleted the note. # # A BASELINE is a ratchet. It carries no argument, caps a unit at the score it already had, # and exists so the gate is adoptable on a codebase that is already red. A unit in it may stay # ugly; it may not get worse. # # Both are keyed by the same unit identity, which is why they share a file. Two key builders is # the drift this arrangement exists to prevent: an acceptance and a baseline that disagree about # what "the same unit" means would each be right on their own terms. # # Everything here is PURE. The file a baseline lives in is read and written by Report.ps1, which # owns document I/O; this file decides what the contents mean. # The baseline FILE format, versioned separately from the report and from the metric. Three # things that change for different reasons: the metric version moves when a score would come out # different, the report version when the published output changes, and this when the shape of a # baseline entry does. $script:PSCxBaselineSchemaVersion = 1 function Get-PSCxBaselineSchemaVersion { # A reader for the constant above, so no other file reaches into this one's module state. The # gate needs the number to compare a file against; it does not need to know where it is kept. [OutputType([int])] [CmdletBinding()] param() return $script:PSCxBaselineSchemaVersion } function Get-PSCxPolicyKey { # The identity a policy is written against. NOT Get-PSCxUnitKey in Ast.ps1, which keys an # AST node during a walk -- a different thing that happened to want the same name. # # Two fields, never one joined string: a File outside the measured root keeps its full path, # and on Windows that starts "C:", so any single-separator key is ambiguous the first time # somebody gates outside their repo. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$File, [Parameter(Mandatory)] [AllowEmptyString()] [string]$Unit ) return "$File`u{241F}$Unit" } function Get-PSCxAcceptanceFault { # Why ONE acceptance fails to describe this run, as text. Nothing when it holds. # # An acceptance is a CHECKABLE CLAIM and not a mute button. It carries a written argument, # and the run fails when the claim stops being true -- the unit was renamed away, or it came # back under the ceilings and nobody deleted the note. A declaration that silently stops # applying is indistinguishable from one nobody has read in a year, and a gate full of those # is the thing this module exists to find in other people's code. # # One acceptance at a time, over the pipeline, so the four rules sit at the top level of a # process block rather than inside a loop. Written as a foreach over the list they cost # their nesting depth each and the function scored 14 against a ceiling of 15 -- passing, # and one rule away from not passing, in the function most likely to grow another rule. # # There is deliberately NO ambiguity arm. A unit identity is unique within a file, so an # exact File+Unit match is one or none by construction -- and a rule that cannot fire looks # exactly like a rule that passes. If unit identity ever stops being unique, this is the # comment that has to change with it. # # Unique is not the same as STABLE, and the difference is why the baseline below refuses # what this accepts. Duplicate definitions are told apart by an ordinal -- Get-Thing#1, #2 -- # which renumbers when a duplicate is inserted above them. An acceptance keyed that way is # re-read by whoever edits the file; a baseline is committed once and reviewed rarely. [OutputType([string])] [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [AllowNull()] $Acceptance, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive ) process { $file = [string]$Acceptance.File $name = [string]$Acceptance.Unit $reason = [string]$Acceptance.Reason if (-not $file -or -not $name) { return "an acceptance needs both File and Unit, got File='$file' Unit='$name'" } if (-not $reason.Trim()) { return "$file $name is accepted with no reason -- an acceptance carries the argument for it, or it is a mute button" } $found = @($Unit | Where-Object { $_.File -eq $file -and $_.Unit -eq $name }) if ($found.Count -eq 0) { return "$file $name is accepted but no such unit was measured -- it was renamed, moved, or is outside the path being gated" } if ($found[0].Cyclomatic -le $MaxCyclomatic -and $found[0].Cognitive -le $MaxCognitive) { return "$file $name is accepted but is within both ceilings -- delete the acceptance, it is no longer an argument about anything" } } } function Get-PSCxUnacceptedUnit { # The units that breach a ceiling and that no policy excuses. Separate from the gate because # the gate is a thin predicate and this is the whole of what it decides. # # BaselineMap is optional and defaults to empty, because two callers want different answers # from the same rule: the gate asks "what still fails", and the baseline writer asks "what # breaches at all" -- the second must not subtract what the current file already permits, or # regenerating would drop every entry it was about to rewrite. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Accept, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive, [AllowEmptyCollection()] [hashtable]$BaselineMap = @{} ) $accepted = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($a in $Accept) { [void]$accepted.Add((Get-PSCxPolicyKey -File ([string]$a.File) -Unit ([string]$a.Unit))) } # Emitted rather than returned as an array, so the declared OutputType describes what a # caller actually receives -- one record at a time. The gate wraps the result in @() and # gets an empty array when nothing breaches, which is the same answer either way. $Unit | Where-Object { ($_.Cyclomatic -gt $MaxCyclomatic -or $_.Cognitive -gt $MaxCognitive) -and -not $accepted.Contains((Get-PSCxPolicyKey -File $_.File -Unit $_.Unit)) -and -not (Test-PSCxWithinBaseline -Unit $_ -Map $BaselineMap) } } function Test-PSCxStableIdentity { # Whether a unit name identifies the same unit across an edit elsewhere in the file. # # Duplicate definitions in one file are told apart by an ORDINAL -- Get-Thing#1, #2, #3 -- # and an ordinal renumbers when a duplicate is inserted above it. Measured: adding a third # Get-Thing at the top moves the unit that was #1 to #2, and #1 then names a function nobody # has seen before, so a recorded score would silently start capping it. # # A line number would be no better and is worse in the ordinary case, where it churns on # every edit above the unit. Nothing about the name is salvageable here, so the entry is # refused rather than approximated. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$Unit ) return -not $Unit.Contains('#') } function Get-PSCxBaselineMap { # Entries as a lookup keyed by unit identity, refusing a file that names one unit twice. # # Two entries for one unit is not a merge to resolve quietly: whichever wins decides what the # gate permits, and the loser reads as a recorded decision that is silently doing nothing. [OutputType([hashtable])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Entry ) $map = @{} foreach ($e in $Entry) { $key = Get-PSCxPolicyKey -File ([string]$e.file) -Unit ([string]$e.unit) if ($map.ContainsKey($key)) { throw ("The baseline names $($e.file) $($e.unit) twice. One of the two decides what " + 'the gate permits and the other silently does nothing, so which one you meant ' + 'cannot be read off the file.') } $map[$key] = $e } return $map } function Test-PSCxWithinEntry { # Whether this unit is at or under the scores one entry records. Named to parallel # Test-PSCxWithinBaseline below: same question, one against an entry and one against a map. # # Both metrics, and both must hold. A unit that traded cyclomatic for cognitive has not stayed # the same, and permitting it because one number fell is how a ratchet loosens. # # The ONE place the comparison lives, because two callers need it and they need the same # answer: the gate asks it to decide what to permit, and the fault check asks it to tell an # improvement from a regression. Two spellings would disagree exactly where a unit moved in # both directions at once -- the case neither author would have thought to test. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] $Unit, [Parameter(Mandatory)] $Entry ) return ([int]$Unit.Cyclomatic -le [int]$Entry.cyclomatic -and [int]$Unit.Cognitive -le [int]$Entry.cognitive) } function Test-PSCxWithinBaseline { # Whether this unit is recorded and has not got worse. False when it is not recorded at all, # which is the answer that makes an un-baselined unit face the real ceilings. [OutputType([bool])] [CmdletBinding()] param( [Parameter(Mandatory)] $Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Map ) $key = Get-PSCxPolicyKey -File ([string]$Unit.File) -Unit ([string]$Unit.Unit) if (-not $Map.ContainsKey($key)) { return $false } return Test-PSCxWithinEntry -Unit $Unit -Entry $Map[$key] } function Get-PSCxBaselineScoreFault { # Why one entry's NUMBERS no longer describe the unit, as text. Nothing when they do. # # Split from the entry checks so neither function carries both kinds of rule: those are about # whether the entry points at anything, these about what it permits. Folded together the # single function sits over the complexity ceiling this module gates itself on. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Entry, [Parameter(Mandatory)] $Unit, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive ) if ($Unit.Cyclomatic -le $MaxCyclomatic -and $Unit.Cognitive -le $MaxCognitive) { return "$($Entry.file) $($Entry.unit) is recorded but is now within both ceilings (cyclomatic $($Unit.Cyclomatic), cognitive $($Unit.Cognitive)) -- delete the entry, the real bar covers it" } # Covered FIRST, then better. A unit can be both at once -- worse on cyclomatic, better on # cognitive -- and those two facts do not weigh the same: the regression is what the gate # exists to catch, and calling it an improvement sends somebody to lower an entry for a unit # that just got worse. Saying nothing here lets it fall through to the ordinary violation # path, which says the true thing. if (-not (Test-PSCxWithinEntry -Unit $Unit -Entry $Entry)) { return } if ($Unit.Cyclomatic -lt [int]$Entry.cyclomatic -or $Unit.Cognitive -lt [int]$Entry.cognitive) { return ("$($Entry.file) $($Entry.unit) improved to cyclomatic $($Unit.Cyclomatic), cognitive $($Unit.Cognitive) " + "from a recorded $($Entry.cyclomatic)/$($Entry.cognitive) -- lower the entry, or run " + 'with -UpdateBaseline. A ratchet that keeps the old number stops being one') } } function Get-PSCxBaselineFault { # Why ONE baseline entry fails to describe this run, as text. Nothing when it holds. # # Same discipline as an acceptance, for the same reason: a recorded permission that has # stopped describing anything is indistinguishable from one nobody has read in a year. The # arms differ because a baseline says something narrower -- not "this is fine" but "this is # no worse than it was". # # One entry at a time over the pipeline, so each rule sits at the top level of a process # block rather than inside a loop, where it would cost its nesting depth against the ceiling # this module gates itself on. [OutputType([string])] [CmdletBinding()] param( [Parameter(ValueFromPipeline)] [AllowNull()] $Entry, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Accept, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive ) process { $file = [string]$Entry.file $name = [string]$Entry.unit if (-not $file -or -not $name) { return "a baseline entry needs both file and unit, got file='$file' unit='$name'" } if (-not (Test-PSCxStableIdentity -Unit $name)) { return ("$file $name is recorded, but the ordinal in that name renumbers when a " + 'duplicate definition is inserted above it, so this entry would start capping a ' + 'different function. Rename one of the duplicates -- the later definition ' + 'shadows the earlier at run time anyway') } if ($Accept | Where-Object { [string]$_.File -eq $file -and [string]$_.Unit -eq $name }) { return "$file $name is both accepted and recorded in the baseline -- the acceptance already excuses it, so the entry permits nothing" } $found = @($Unit | Where-Object { $_.File -eq $file -and $_.Unit -eq $name }) if ($found.Count -eq 0) { return "$file $name is recorded but no such unit was measured -- it was renamed, moved, or is outside the path being gated" } return Get-PSCxBaselineScoreFault -Entry $Entry -Unit $found[0] ` -MaxCyclomatic $MaxCyclomatic -MaxCognitive $MaxCognitive } } function Get-PSCxBaselineDocumentFault { # Why a baseline FILE cannot be compared against this run at all, as text. Nothing when it can. # # Separate from the per-entry rules because these refuse the whole document: once the numbers # are known to mean something else, there is no sensible verdict to reach entry by entry. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowNull()] $Document, [Parameter(Mandatory)] [int]$MetricVersion, [Parameter(Mandatory)] [int]$SchemaVersion ) if ($null -eq $Document) { return 'the baseline file held no document' } # -ne rather than -lt: a file written by a NEWER PSComplexity is equally uncomparable, and # reading it as though it were current is the direction that fails silently. if ([int]$Document.schemaVersion -ne $SchemaVersion) { return ("the baseline is schemaVersion $($Document.schemaVersion) and this PSComplexity " + "writes $SchemaVersion -- regenerate it with -UpdateBaseline") } if ([int]$Document.metricVersion -ne $MetricVersion) { return ("the baseline was recorded against metric version $($Document.metricVersion) and " + "this run measures $MetricVersion -- the numbers are not comparable, so every entry " + 'would be a guess. Regenerate it with -UpdateBaseline and review the diff') } if ($null -eq $Document.units) { return 'the baseline has no units array -- an empty baseline records an empty array, not nothing' } } function Get-PSCxBaselineRaise { # The units an update would have to record WORSE than the file already does. # # The one thing -UpdateBaseline must never do. A ratchet that absorbs a regression when you # re-run the tool is a suppression list with extra steps, and the regression it absorbs is # exactly the one the gate existed to catch. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [hashtable]$Map ) $Unit | Where-Object { $Map.ContainsKey((Get-PSCxPolicyKey -File ([string]$_.File) -Unit ([string]$_.Unit))) -and -not (Test-PSCxWithinBaseline -Unit $_ -Map $Map) } } function Get-PSCxBaselineDocument { # The baseline to write: every unit that breaches a ceiling and that nobody has accepted, at # the score it has now. # # Units are ordered by file then unit so a regenerated file has a readable diff. Left # unordered, enumeration reshuffles the whole document on every run and the one line that # changed is unfindable in review -- which is where a ratchet is actually enforced. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unit, [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Accept, [Parameter(Mandatory)] [int]$MaxCyclomatic, [Parameter(Mandatory)] [int]$MaxCognitive, [Parameter(Mandatory)] [int]$MetricVersion, [Parameter(Mandatory)] [int]$SchemaVersion, [Parameter(Mandatory)] [datetime]$GeneratedAt ) $breaching = @(Get-PSCxUnacceptedUnit -Unit $Unit -Accept $Accept -MaxCyclomatic $MaxCyclomatic -MaxCognitive $MaxCognitive) $entries = @($breaching | Sort-Object File, Unit | ForEach-Object { [pscustomobject]@{ file = $_.File unit = $_.Unit cyclomatic = [int]$_.Cyclomatic cognitive = [int]$_.Cognitive } }) return [pscustomobject]@{ schemaVersion = $SchemaVersion metricVersion = $MetricVersion generatedAt = $GeneratedAt.ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') units = $entries } } |