src/Scan.ps1
|
# The scan: turning paths into measured units, as DATA. # # Apart from Measure-PSComplexity.ps1, which holds the two public projections over this. The # reason is measured rather than aesthetic: a covering suite is re-run once per mutant of the file # it covers, and tests/Measure.Tests.ps1 takes 18 seconds because 134 tests each measure real # source -- profiled, 16.7s of that is test bodies rather than fixture setup, so making the # fixtures cheaper would not have helped. These 35 mutants were paying for it and need one small # fixture instead. # # The split follows a seam the docstring already named: "the scan -- measurement as data -- and # the two public projections over it". This is the first half. function Get-PSCxSourceFile { # Resolve a path (file or directory) to the PowerShell source files to measure. # # [string[]] and not [string], unlike the streaming collectors: this RETURNS the whole # collection as one value rather than emitting items, so the array form is the accurate # declaration. The analyzer says so, which is how the distinction was found. [OutputType([string[]])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Path, [switch]$Recurse) if (Test-Path -LiteralPath $Path -PathType Leaf) { return [string[]]@((Resolve-Path -LiteralPath $Path).Path) } # Take an existing path literally, and as a wildcard only when nothing is there. -Path # glob-parses '[', so a real directory named 'my[1]proj' matches nothing and the scan # reports a clean zero over code it never opened. # # A path that is neither returns an empty list HERE rather than reaching Get-ChildItem, which # would write a PathNotFound error of its own. That error was worse than useless: it named # this file and this line, it was attributed to Get-ChildItem rather than to the module, and # it did not reach the caller's -ErrorVariable -- so the only thing a consumer saw was zero # units. Get-PSCxPathScan records the empty result as a fact instead, and the gate refuses it. $gci = @{ File = $true; Recurse = [bool]$Recurse } if (Test-Path -LiteralPath $Path) { $gci.LiteralPath = $Path } elseif (Test-Path -Path $Path) { $gci.Path = $Path } else { return [string[]]@() } # Filter on the extension rather than with -Include, which a directory ignores unless # -Recurse is also present: a flat folder would resolve to zero files and every number # after it would describe the empty set. return [string[]]@(Get-ChildItem @gci | Where-Object { $_.Extension -in '.ps1', '.psm1' } | ForEach-Object { $_.FullName }) } # Which metric produced a number. Two scores are comparable only if the same metric produced # them, and this module's metric has already moved twice for unchanged source: 0.3.0 taught it # PowerShell's own flow constructs, and 0.4.0 stopped merging two units written on one line. # Both were correct, and both silently re-scored code nobody had touched. # # It increments whenever a score can change for source that did not, which is a narrower rule # than the module version: a bug fix that only affects messages, or a new field on the record, # leaves it alone. Anything persisting or comparing scores -- a committed baseline, a diffed # report -- must refuse to compare across two different values rather than mix them. # # Starts at 1 with 0.4.0. Scores from earlier releases carry no version and are not comparable # with these; that is a statement about what was never recorded, not a claim that they agree. $script:PSCxMetricVersion = 1 function Get-PSCxRelativePath { # A path two machines can agree on. # # File used to be absolute and platform-separated, so the same source measured on the two # CI legs produced disjoint key sets -- C:\...\src\A.ps1 against /home/.../src/A.ps1 -- # and anything comparing runs across them matched nothing while looking like it worked. # The README had rendered it relative all along, so the absolute form was not a decision # anyone recorded. # # Separators are normalised to '/' because that is the half that does not vary: a Windows # reader understands src/A.ps1, and a key containing a backslash cannot be matched by a # Linux run at all. # # The PLATFORM separator, not a literal backslash. On Linux a backslash is an ordinary # filename character, so replacing it there corrupts a legal path -- and the replacement # is a no-op on the one platform where the self-mutation gate runs, which is how a # hard-coded one survived every mutant while looking tested. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$Path, [Parameter(Mandatory)] [string]$Root ) # A RELATIVE Path resolves against Root, not against the working directory. GetFullPath # alone silently uses the CWD, so the function only gave the right answer while its one # caller happened to pass an absolute path AND a Root equal to the CWD -- two conditions # that both had to hold and neither of which was stated. The sibling project shipped the # same shape and it was a live hole there, because a config may name a file by full path. $full = if ([System.IO.Path]::IsPathRooted($Path)) { [System.IO.Path]::GetFullPath($Path) } else { [System.IO.Path]::GetFullPath((Join-Path $Root $Path)) } $rootFull = [System.IO.Path]::GetFullPath($Root) if (-not $rootFull.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { $rootFull += [System.IO.Path]::DirectorySeparatorChar } # Outside the root, keep the full path. A ../../ chain says less than the absolute path # does and is no more portable, so pretending would only hide where the file came from. if (-not $full.StartsWith($rootFull, [System.StringComparison]::OrdinalIgnoreCase)) { return $full.Replace([System.IO.Path]::DirectorySeparatorChar, '/') } return $full.Substring($rootFull.Length).Replace([System.IO.Path]::DirectorySeparatorChar, '/') } function Get-PSCxChangedSet { # The caller's changed-file list, normalised into a set the scan can compare against. # # Both sides have to be spelled the same way or the filter matches nothing and the run reports # a confident pass over zero units. A list comes from git as repo-relative with forward # slashes; a File on a record comes from Get-PSCxRelativePath. Putting both through the same # function is what makes 'src/A.ps1', './src/A.ps1', 'src\A.ps1' and an absolute path the same # entry. # # OrdinalIgnoreCase for the same reason the rest of this module uses it: a config that fails on # one platform and not the other is worse than one that fails on both. [OutputType([System.Collections.Generic.HashSet[string]])] [CmdletBinding()] param( # AllowEmptyString as well as AllowEmptyCollection: a Mandatory [string[]] rejects a # blank ENTRY, and `git diff --name-only` piped through PowerShell routinely yields one # as a trailing line. Without it the blank-skipping below is unreachable and the caller # gets a binding error instead. [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowEmptyString()] [string[]]$ChangedFile, [Parameter(Mandatory)] [string]$Root ) $set = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($f in $ChangedFile) { if ([string]::IsNullOrWhiteSpace($f)) { continue } [void]$set.Add((Get-PSCxRelativePath -Path $f.Trim() -Root $Root)) } # -NoEnumerate, because a HashSet is enumerable and PowerShell unrolls it on the way out: an # empty one becomes $null and a one-element one becomes the STRING it holds -- both of which # fail to bind several frames away from the return that caused them. The comma-wrap does the # same job and declares an object[], which is not what this returns. Write-Output -InputObject $set -NoEnumerate } function Get-PSCxUnmatchedPath { # One record for a requested path that produced no source file: what was asked for, why it # produced nothing, and whether the path is THERE at all. # # Both facts, because the two consumers need different ones. They are different situations: # a path that is not there is a typo or a wrong working directory, and nothing downstream can # be right; a path that IS there and holds no PowerShell is an ordinary outcome -- a docs # directory, a tree filtered down to nothing -- and a measurement command that treated it as # a fault would fail every legitimately empty run. The gate refuses both, because a ceiling # applied to nothing is not a gate; Measure-PSComplexity reports only the first. # # Its own function rather than a conditional inside the walk: Get-PSCxPathScan already carries # its loop's nesting against the ceiling this module gates itself on. [OutputType([pscustomobject])] [CmdletBinding()] param([Parameter(Mandatory)] [string]$Path) # Both spellings, matching Get-PSCxSourceFile: a directory named 'my[1]proj' is real and only # -LiteralPath sees it, while a wildcard is real and only -Path resolves it. $exists = (Test-Path -LiteralPath $Path) -or (Test-Path -Path $Path) $reason = 'no such path' if ($exists) { $reason = 'holds no .ps1 or .psm1 file' } return [pscustomobject]@{ Path = $Path; Reason = $reason; Exists = $exists } } function Get-PSCxUnmatchedPathFault { # Why the paths this run was asked for do not describe it, as text. Nothing when they all # matched something. # # The mixed case is the one this exists for, and it is the failure the whole module is about # aimed inward: Get-PSCxEmptyScanFault below fires only when the run measured NOTHING, so a # single valid path masked any number of mistyped ones. Measured, before this existed: # Test-PSComplexity @('./src/Ast.ps1', '/nope') returned $true -- a green gate over a path # that does not exist. # # Separate from the empty-scan fault rather than folded into it, because both are reachable # and they say different things: every path wrong is "you measured nothing", some paths wrong # is "you measured less than you asked for". A run where every path is wrong measures nothing # and is refused by the other rule first. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unmatched) if ($Unmatched.Count -eq 0) { return $null } return ('Refusing to vouch for a run that could not read ' + $Unmatched.Count + ' of the path(s) it was given: ' + (($Unmatched | ForEach-Object { "$($_.Path) -- $($_.Reason)" }) -join '; ') + '. A gate that skips what it cannot find reports a pass over the part it did reach, ' + 'which reads as a stronger claim than it is. Fix the path, or stop naming it.') } function Get-PSCxEmptyScanFault { # Why a run that measured nothing cannot be believed, or $null when it can. # # "No unit breached a ceiling" and "no unit was measured" are the same $true, so a gate # pointed at the wrong place reports clean -- the failure this module exists to find in other # people's code. # # NOT for a diff-scoped run. There, measuring nothing is an ordinary outcome -- a pull request # touching only markdown -- and refusing it would fail every such build. What keeps that # honest is the subset notice below, not a refusal; and the empty changed-file LIST, which is # the case that actually indicates a broken diff command, is refused separately. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] [int]$UnitCount, [Parameter(Mandatory)] [bool]$Filtered, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Path, [Parameter(Mandatory)] [bool]$Recurse ) if ($UnitCount -gt 0 -or $Filtered) { return $null } $hint = if ($Recurse) { '' } else { ', or add -Recurse if they are in subdirectories' } return ('Measured no units under: ' + ($Path -join ', ') + '. Nothing was checked, so a pass ' + 'here would describe an empty set. Check the path exists and holds .ps1 or .psm1 files' + $hint + '.') } function Get-PSCxSubsetNotice { # What a diff-scoped run must say out loud, or $null for a whole-tree run. # # A filtered pass reported as a whole-tree pass is worse than no gate: it reads as a stronger # claim than it is, and the reader has no way to tell. The gate prints nothing at all when it # passes, so without this a run over two files and a run over the repository are the same # silence. # # Text rather than a Write-Warning here, so the sentence is a value a test can compare and the # caller decides which stream it belongs on. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] $Scan) if ($null -eq $Scan.Scope.ChangedFile) { return $null } $files = @($Scan.Scope.ChangedFile).Count $units = @($Scan.Units).Count return ("Measured $units unit(s) from $files changed file(s), not the whole tree. This verdict " + 'covers only what -ChangedFile named; it is not a statement about the rest of the code.') } function Get-PSCxScanFault { # Why this measurement cannot be gated on at all, as text. Nothing when it can. # # Both scan-level refusals in ONE place, in the order they have to fire, because that order is # a decision rather than a detail. Every path wrong measures nothing and is answered by the # count rule, which carries the -Recurse hint; SOME paths wrong measures plenty and is # answered by the path rule. Reversed, the count rule could never fire -- and a rule that # cannot fire looks exactly like a rule that keeps passing. # # Written here rather than as two consecutive ifs in Test-PSComplexity for the reason that # command keeps every other decision out of itself: it is a thin predicate, and the ordering # is now something a test can hold rather than something a reader has to infer from statement # order. [OutputType([string])] [CmdletBinding()] param( [Parameter(Mandatory)] $Scan, [Parameter(Mandatory)] [AllowEmptyCollection()] [string[]]$Path, [Parameter(Mandatory)] [bool]$Filtered, [switch]$Recurse ) $empty = Get-PSCxEmptyScanFault -UnitCount @($Scan.Units).Count -Path $Path ` -Recurse:$Recurse -Filtered $Filtered if ($empty) { return $empty } return Get-PSCxUnmatchedPathFault -Unmatched @($Scan.Unmatched) } function Assert-PSCxChangedFile { # Refuse a changed-file list that is empty, naming why. # # An empty list means "nothing changed", and taken at face value it produces a confident pass # over zero units -- the vacuous score this module exists to find in other people's code, # aimed inward. It is also the single most likely thing a caller passes by accident: a git # command that failed, matched nothing, or ran against a shallow clone where the base ref does # not exist prints nothing and exits 0. # # A caller who genuinely has no changed files wants to SKIP the gate, and that is a decision # only they can make -- so it is theirs to make, in their own script, where it is visible. [OutputType([void])] [CmdletBinding()] param( # AllowEmptyString for the same reason as Get-PSCxChangedSet: a blank entry is what a # diff emits as a trailing line, and rejecting it at the binder turns the refusal this # function exists to make into a message about parameter binding. [Parameter(Mandatory)] [AllowEmptyCollection()] [AllowNull()] [AllowEmptyString()] [string[]]$ChangedFile ) if (@($ChangedFile | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }).Count -gt 0) { return } throw ('-ChangedFile was given no files. An empty list would restrict the run to nothing and ' + 'report a pass over zero units, which is the failure this module exists to find -- and it ' + 'is what a diff command prints when it fails, matches nothing, or runs against a shallow ' + 'clone whose base ref is missing. If nothing changed, skip the gate rather than asking it ' + 'to measure an empty set.') } function Write-PSCxUnmatchedPath { # Render each path that produced no source file to the error stream. # # Write-Error, matching how a skipped file is reported, and for the same reason: this is the # module admitting it did not measure something it was asked to, and CI logs routinely swallow # warnings. It is a RENDERING of a fact the scan already holds, never the only place that fact # exists -- which is what lets the gate refuse the same paths as data. # # Its own function because both of Measure-PSComplexity's routes need it and neither owns it. [OutputType([void])] [CmdletBinding()] param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]]$Unmatched) foreach ($u in $Unmatched) { # A path that exists and holds no PowerShell is an ordinary empty measurement, not a # fault: this command applies no thresholds and reaches no verdict, so refusing one would # turn measurement into judgement -- and under ErrorActionPreference = Stop it would # terminate a run over a directory the caller knows is empty. The gate refuses it there, # where a ceiling applied to nothing genuinely is a broken gate. if ($u.Exists) { continue } Write-Error "Measured nothing under '$($u.Path)' -- $($u.Reason)" } } function Get-PSCxUnitRecord { # Shape one output record. The only place the published shape is decided. # # A function rather than inline, because the caller sits inside a per-unit loop and a # conditional there costs its nesting depth rather than one point. Folded back into that # loop, the -Detailed branch alone puts Measure-PSComplexity over the cognitive ceiling # this module gates itself on. # # Get-, not New-, although it builds something: New- is a state-changing verb, so # PSUseShouldProcessForStateChangingFunctions demands -WhatIf support this has no use for. # It also matches the Get-PSCx*Row composers, which build rows the same way. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [AllowEmptyString()] [string]$File, [Parameter(Mandatory)] [string]$Unit, [Parameter(Mandatory)] [int]$Line, [Parameter(Mandatory)] [int]$Cyclomatic, [Parameter(Mandatory)] [int]$Cognitive, [Parameter(Mandatory)] [int]$MetricVersion, [AllowEmptyCollection()] [object[]]$Contributions, [switch]$Detailed ) $record = [pscustomobject]@{ File = $File Unit = $Unit Line = $Line Cyclomatic = $Cyclomatic Cognitive = $Cognitive MetricVersion = $MetricVersion } # Absent and empty are different answers, and a consumer iterating this should not have to # tell them apart -- so -Detailed always adds the property, even for a decision-free unit. if ($Detailed) { $record | Add-Member -NotePropertyName Contributions -NotePropertyValue @($Contributions) } return $record } function Get-PSCxSkipReason { # Why a file produced no units, as text, told apart by what actually went wrong. # # Parser::ParseFile reports an I/O failure through the SAME out-parameter as a syntax error -- # a missing file, a directory, a permission denial and a deleted-mid-scan file all arrive as a # ParseError. Calling every one of them a "parse error" sent the reader to inspect syntax that # was perfectly correct, and the gate's own advice compounded it by saying "fix the syntax". # # Discriminated on ErrorId rather than on the message text, which is prose and may be # localised. FileReadError is what the parser raises when it could not read the bytes at all. [OutputType([string])] [CmdletBinding()] param([Parameter(Mandatory)] $ParseError) if ($ParseError.ErrorId -eq 'FileReadError') { return "could not be read: $($ParseError.Message)" } return "parse error: $($ParseError.Message)" } function Get-PSCxFileScan { # One file's measurement AS DATA: the units it produced, or the reason it produced none. # # Nothing is written to the error stream here, and that is the point. A skip that exists # only as an error is a fact the caller has to rebuild by capturing the stream and reading # message text -- which is what the gate did, with -ErrorAction SilentlyContinue, so any # unrelated failure reached the user described as a parse error. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string]$File, [switch]$Detailed ) $errors = $null $ast = [System.Management.Automation.Language.Parser]::ParseFile($File, [ref]$null, [ref]$errors) # The per-node answer tables are for THIS file. Keyed by node reference they can never answer # wrongly across parses, so this is about memory rather than correctness: without it a gate # over a large tree holds every node of every file it has seen. Clear-PSCxAstCache # Relative to where the caller is standing, which for a repo-scoped run is the repo. Not a # parameter: a root nobody passes is a root nobody gets wrong. Resolved for BOTH arms, so a # skipped file is named the same way a measured one is. $relative = Get-PSCxRelativePath -Path $File -Root (Get-Location).Path if ($errors) { # The FIRST error. Later ones are usually cascade from it, and a skip that does not say # why is the one thing the reason string exists to prevent. return [pscustomobject]@{ File = $relative Units = @() SkipReason = Get-PSCxSkipReason -ParseError $errors[0] } } # ONE traversal, shared. Get-PSCxUnitTable is a full FindAll walk that invokes a PowerShell # predicate for every node, and it used to run three times against the same AST in a single # pass -- once here for the line numbers, and once inside each metric map. $lines = Get-PSCxUnitTable -Ast $ast # Each row set collected ONCE, here, and projected into whatever the run needs. The cognitive # rows are needed twice -- summed into the map, and grouped per unit for -Detailed -- and # collecting them inside each map meant -Detailed walked the tree a second time for rows that # already existed. $cyc = Get-PSCxCyclomaticMap -Row @(Get-PSCxCyclomaticRow -Ast $ast) -UnitTable $lines $cogRows = @(Get-PSCxCognitiveRow -Ast $ast) $cog = Get-PSCxCognitiveMap -Row $cogRows -UnitTable $lines # Source order, not hashtable order. .NET enumerates a hashtable by bucket layout, which is # neither insertion nor line order and is not required to be stable -- so two runs over one # unchanged file could emit the same rows in different sequences. Nothing here noticed, # because the gate takes a max and a human reads a table; a committed baseline or a diffed # report would have. # # Line first, then unit name: two units CAN start on the same line # (`function A { } function B { }`), and a tie left unbroken puts the nondeterminism # straight back. $ordered = @($lines.Keys | Sort-Object @{ Expression = { $lines[$_] } }, @{ Expression = { $_ } }) # Names computed over the whole file, because disambiguating a repeat needs to know there # IS a repeat. Done per row, the first of a pair could not be told apart from a unit that # never had a twin. $display = Get-PSCxDisplayName -OrderedKeys $ordered # Once per file rather than per unit: the rows are one walk, and asking for them per unit # would re-walk the tree for every function in the file. $byUnit = @{} if ($Detailed) { $byUnit = Get-PSCxContributionMap -Row $cogRows } $units = foreach ($k in $ordered) { # Two traps stacked here, and the empty list has to survive BOTH. # # A decision-free unit has no entry, and @($byUnit[$k]) on a missing key gives an array # of ONE null rather than an empty one -- a contribution with no line, no construct and # no amount, which is worse than the absent property this exists to avoid because it # survives a count check. # # And the fix cannot be written as `$c = if (...) { ... } else { @() }`: an if used as # an expression yields its branch's OUTPUT, and emitting @() emits nothing, so the # variable lands back on $null. Assign first, then overwrite. $contrib = @() if ($byUnit.ContainsKey($k)) { $contrib = $byUnit[$k] } Get-PSCxUnitRecord -File $relative -Unit $display[$k] -Line $lines[$k] ` -Cyclomatic $cyc[$k] -Cognitive $cog[$k] -MetricVersion $script:PSCxMetricVersion ` -Contributions $contrib -Detailed:$Detailed } return [pscustomobject]@{ File = $relative; Units = @($units); SkipReason = $null } } function Get-PSCxPathScan { # Every file under Path, measured, ONE per-file scan at a time. # # Streaming on purpose. Measure-PSComplexity emits units as it walks, and the aggregate # below is a fold over this -- so both public commands share one walk without forcing the # streaming one to buffer a whole tree to get it. # # -Seen belongs to the caller, because dedup has to span pipeline input: paths arrive one # invocation at a time, and a set created here would forget the previous one. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Path, # AllowEmptyCollection, because an empty set is the NORMAL starting point and # Mandatory alone rejects it. Without this the binding failure surfaces wherever the # caller happens to report errors -- the gate described it as a file that did not # parse, which is the same class of misdiagnosis this scan exists to end. [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.HashSet[string]]$Seen, # Paths that resolved to no source file at all. Caller-owned for the same reason -Seen is: # paths arrive one invocation at a time down the pipeline, and a list created here would # forget the previous one -- so the run could not say afterwards what it never read. [Parameter(Mandatory)] [AllowEmptyCollection()] [System.Collections.Generic.List[object]]$Unmatched, # $null means no filter. An EMPTY set would be ambiguous -- "nothing changed" and "no # filter was asked for" are different answers -- so the public commands refuse an empty # list rather than passing one down. [System.Collections.Generic.HashSet[string]]$ChangedSet, [switch]$Recurse, [switch]$Detailed ) $root = (Get-Location).Path foreach ($p in $Path) { $files = @(Get-PSCxSourceFile -Path $p -Recurse:$Recurse) # Recorded BEFORE the changed-file filter, because "this path is not there" and "nothing in # this path changed" are different answers and only the first one is a mistake. if ($files.Count -eq 0) { $Unmatched.Add((Get-PSCxUnmatchedPath -Path $p)) continue } foreach ($file in $files) { # OrdinalIgnoreCase: Windows and macOS resolve the same file under different # casing, and a case-sensitive check would let those through as two files. if (-not $Seen.Add($file)) { continue } # BEFORE the parse, so a diff-scoped run over a large tree does not read every file # only to throw most of them away. The verdict is identical either way; the cost is # not, and cost is most of why anyone asks for a diff-scoped run. if ($null -ne $ChangedSet -and -not $ChangedSet.Contains((Get-PSCxRelativePath -Path $file -Root $root))) { continue } # BEFORE the parse, not after. A line written afterwards names files that are # already done, so a scan stuck on one file looks exactly like a scan that has # finished -- which is the whole complaint. Named here, the last line printed IS # the file being read. # # Verbose rather than a progress bar or a default-on line: this is the command a # CI gate calls, and a gate that chatters gets its output filtered, which takes # the parse errors with it. Write-Verbose "Measuring $file" Get-PSCxFileScan -File $file -Detailed:$Detailed } } } function Get-PSCxScan { # The complete measurement: what was asked for, what was measured, and what was not. # # The record stream Measure-PSComplexity publishes is a PROJECTION of this -- the same # relationship a cognitive map has to its rows. Facts about the RUN, which files were # skipped and why and what was in scope, have nowhere else to live: without this they are # destroyed at emission and every consumer rebuilds them from whatever leaked out. # # Internal deliberately. This is the shape a report, a changed-files run and a committed # baseline all need, and settling it in one place is what stops each inventing a private # version and the first one shipped becoming the contract by accident. [OutputType([pscustomobject])] [CmdletBinding()] param( [Parameter(Mandatory)] [string[]]$Path, # The files a caller says changed, or the parameter omitted for a whole-tree scan. It is # recorded in Scope, because a number over a subset that cannot say WHICH subset is worse # than no number: it reads as a stronger claim than it is. [AllowEmptyCollection()] [string[]]$ChangedFile, [switch]$Recurse, [switch]$Detailed ) $units = [System.Collections.Generic.List[object]]::new() $skipped = [System.Collections.Generic.List[object]]::new() $unmatched = [System.Collections.Generic.List[object]]::new() $seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $root = (Get-Location).Path # $null when no filter was asked for, which is what tells the walk to read everything. Built # from $PSBoundParameters rather than from the value, so an explicitly empty list is # distinguishable from an absent one -- the public commands refuse the first. $changedSet = $null if ($PSBoundParameters.ContainsKey('ChangedFile')) { $changedSet = Get-PSCxChangedSet -ChangedFile $ChangedFile -Root $root } foreach ($fileScan in (Get-PSCxPathScan -Path $Path -Seen $seen -Unmatched $unmatched -ChangedSet $changedSet -Recurse:$Recurse -Detailed:$Detailed)) { if ($fileScan.SkipReason) { $skipped.Add([pscustomobject]@{ File = $fileScan.File; Reason = $fileScan.SkipReason }) continue } $units.AddRange([object[]]$fileScan.Units) } # Assigned OUT of the hashtable literal, and typed. A $( ) subexpression UNROLLS a # one-element array to the element, so a run filtered to a single file recorded a bare string # where every consumer expects a list -- and the report then serialised it as one, which the # published schema rejects. Same trap as returning an empty HashSet. $changed = $null if ($null -ne $changedSet) { $changed = [string[]]@($changedSet | Sort-Object) } return [pscustomobject]@{ Units = @($units) Skipped = @($skipped) # Paths that produced no source file. Beside Skipped rather than folded into it: a file # found and not measured and a path never found at all are answered by different fixes, # and an aggregate that cannot say which it hit is the shape this project distrusts. Unmatched = @($unmatched) Scope = [pscustomobject]@{ Path = @($Path) Recurse = [bool]$Recurse Root = $root # $null, not @(), when no filter was asked for. Absent and empty are different # answers here: a consumer has to be able to tell a whole-tree run from one filtered # down to nothing, and only one of those may be read as a full measurement. ChangedFile = $changed } MetricVersion = $script:PSCxMetricVersion } } function Get-PSCxMetricVersion { # A reader for the constant above, so no other file reaches into this one's module state. The # gate needs the number to stamp a baseline with; it does not need to know where it is kept. [OutputType([int])] [CmdletBinding()] param() return $script:PSCxMetricVersion } |