Skip to content

About

Cyclomatic + cognitive complexity for PowerShell - faithful SonarSource metric, AST-based, with a CI gate.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Repository files navigation

PSComplexity

Cyclomatic and cognitive complexity for PowerShell.

Cyclomatic complexity counts control-flow branches (how many paths to test). Cognitive complexity — the SonarSource metric — measures how hard code is to understand: it rewards flat code and penalises nesting. PSComplexity computes both per unit (each function/filter, each class method, constructor and initialised property, plus one <script-body> per file) straight from the PowerShell AST, and ships a CI gate.

To our knowledge PSComplexity is the first module on the PowerShell Gallery to offer a cognitive-complexity metric at all (cyclomatic exists only in the unmaintained PSCodeHealth). It implements the SonarSource metric in full, and extends it for PowerShell constructs the specification does not cover — every reference example scores exactly as published, see tests/Cognitive.Tests.ps1. The extensions are listed below.

Install

Install-Module PSComplexity -Scope CurrentUser   # PowerShell 7.0+

Use

Import-Module PSComplexity

# Report every unit, most complex first:
Measure-PSComplexity ./src -Recurse | Sort-Object Cognitive -Descending |
    Format-Table File, Unit, Line, Cyclomatic, Cognitive

# Gate a build (returns $false + a warning per offender):
if (-not (Test-PSComplexity ./src -Recurse -MaxCyclomatic 15 -MaxCognitive 15)) {
    throw 'Complexity gate failed'
}

Measure-PSComplexity accepts files or directories (pipeline-friendly) and emits objects:

File                    Unit                 Line Cyclomatic Cognitive
----                    ----                 ---- ---------- ---------
src/Foo.ps1             <script-body>           1          1         0
src/Foo.ps1             Get-Foo                12          8         9
src/Foo.ps1             Order.Process          31          5        10

A unit name identifies one unit. Class members are reported as Class.Member, a function nested inside another as Outer/Inner, and units sharing a name in the same scope — two overloads, or a function defined twice — carry an ordinal on every member of the group (Repo.Add#1, Repo.Add#2). Suffixing only the second would silently rename the first the day an overload is added. So two classes with a method of the same name — or a class method and a function of that name — stay distinct, in the output and in any per-unit baseline built from it. A class property is a unit only when it has an initialiser: that is code which runs, and it belongs to the property rather than to the enclosing script body.

A function's scope modifier is not part of its name: function global:Get-Fact is the unit Get-Fact, so adding or removing global: does not orphan its baseline entry or acceptance. The cost is that a global: and a plain definition of one name in one file are duplicates, and number as such.

The two metrics

Cyclomatic = 1 + decision points (each if/elseif/switch clause, each for/foreach/while/do loop, each catch/trap, each ternary, each -and/-or).

Cognitive (the SonarSource rules; the PowerShell extensions follow):

Rule Effect
B1 structural (+1 each) if, else/elseif, switch, loops, catch/trap, ternary, a labelled break/continue, each maximal run of -and/-or, each direct recursive call (see Recursion)
B2 nesting (+depth) added to if (leading clause), switch, loops, catch/trap, ternary — not to else/elseif, boolean runs, labelled jumps, or recursion
B3 nesting level raised by the structures above and by nested script-block lambdas (e.g. a ForEach-Object { } body)

So a flat function scores 0; a switch scores 1 regardless of case count; a deeply nested loop-in-loop-in-if grows fast — mirroring how hard it is to follow.

API

Command Returns Purpose
Measure-PSComplexity -Path <files/dirs> [-Recurse] [-Detailed] [-ReportPath <file>] per-unit records inspect / report
Test-PSComplexity -Path <files/dirs> [-Recurse] [-MaxCyclomatic 15] [-MaxCognitive 15] [-Accept <declarations>] [-ReportPath <file>] [-SarifPath <file>] [bool] CI gate (warns per offender)

Paths in, by value or by property

Both commands take -Path positionally, by value down the pipeline, and by property name, with FullName aliased onto it. All four of these bind correctly:

Measure-PSComplexity ./src -Recurse
'./src/A.ps1', './src/B.ps1' | Measure-PSComplexity
Get-ChildItem ./src -Filter *.ps1 -Recurse | Measure-PSComplexity
Get-ChildItem ./src -Recurse | Where-Object Length -gt 4kb | Measure-PSComplexity

The third worked before only by coercion -- a FileInfo has no Path property, so it reached the parameter through ToString(). Anything carrying its path as a property, which is what the fourth produces, bound to nothing and measured nothing, silently. PSPath is not aliased: every file and directory object already carries FullName, so it would add no input that does not already bind. A provider-qualified path passed as -Path is resolved like any other.

Script blocks and AST objects are not accepted. This measures files on disk, and anything else is reported as a path that matched nothing.

A path that produced no PowerShell is a fact, not a silence. Measure-PSComplexity writes an error naming any path that is not there; a path that is there and simply holds no .ps1 or .psm1 is an ordinary empty measurement and is not an error, because this command applies no thresholds. Test-PSComplexity refuses both, because a ceiling applied to nothing is not a gate:

Test-PSComplexity @('./src', './scr')   # throws, naming ./scr -- it used to return $true

That last line is the reason this exists. The empty-scan guard counted units, so one valid path masked any number of mistyped ones, and the underlying Get-ChildItem error never reached the caller. A green gate over a path that does not exist is the exact failure this module is for.

The record is the API

Measure-PSComplexity emits one object per unit with exactly these fields, in this order:

Field Type
File [string] path relative to the working directory, forward slashes; full path if outside it
Unit [string] function, method, or <script-body>
Line [int] where the unit starts
Cyclomatic [int]
Cognitive [int]
MetricVersion [int] which metric produced these numbers

A File is compared the way the working directory's file system compares names: read once per run by looking up an existing name with its case flipped. So a project on Linux, inside WSL (even from Windows PowerShell, through \\wsl.localhost) or in a case-sensitive Windows directory is compared case-sensitively, and src/A.ps1 and src/a.ps1 are two files, both measured and each with its own entries. That one rule covers measuring, -ChangedFile, -Accept and the baseline alike. Only when the directory holds no name with a letter in it does the platform decide: Linux case-sensitive, Windows and macOS not. A Unit name always ignores case, as PowerShell does.

A case-sensitive folder inside a case-insensitive project is the one case the run cannot key: two files there differing only in case would be one entry in any baseline. Neither is dropped -- the second is reported as skipped, naming the first, and the gate refuses the run until one is renamed.

MetricVersion increments whenever a score can change for source that did not -- a narrower rule than the module version, so a bug fix that only affects messages, or a new field on this record, leaves it alone. It has happened twice: 0.3.0 taught the metric PowerShell's own flow constructs, and 0.4.0 stopped merging two units written on one line. Both were corrections, and both silently re-scored code nobody had touched. Anything that persists or compares scores should refuse to compare across two values rather than mix them. It starts at 1 with 0.4.0; earlier releases carry no version and are not comparable with these. It is 2 from 0.6.0, which scores the .ForEach() and .Where() methods as the cmdlets they mirror.

Those names, their order and their types are the public contract, and a test asserts them exactly -- a sixth field fails the suite, so widening this is a decision rather than a side effect. Line is deliberately not an identity: it moves whenever anything above a unit is edited, so it says where a unit currently starts, not which unit it is.

Where a score came from

A unit comes back as Cognitive = 23. That number is correct and, on its own, unactionable: it does not say whether it is one deeply-nested loop or twenty flat guards, and those call for opposite fixes. -Detailed adds a Contributions list holding every increment that made up the total:

# Thing.ps1
# 1  function Invoke-Thing {
# 2      param($a, $b)
# 3      & {
# 4          if ($a) {
# 5              if ($a -and $b) { 'x' }
# 6          }
# 7      }
# 8  }

$unit = Measure-PSComplexity ./Thing.ps1 -Detailed | Where-Object Unit -eq 'Invoke-Thing'
$unit.Contributions | Format-Table Line, Construct, Amount
Line Construct   Amount
---- ---------   ------
   4 if               2
   5 boolean-run      1
   5 if               3

Cognitive = 6, and the breakdown says where: the if on line 4 costs 2 rather than 1 because the & { } lambda already raised the nesting level, and the one on line 5 costs 3 for sitting inside it. Two ifs and one -and would be 3 points written flat; the same three constructs nested cost 6.

Read the amounts, not just the count. A +1 is a structure; anything larger is that structure plus its nesting depth, because nesting is what the B2 rule charges for. So four +1 rows and one +4 row reach the same total and mean different things -- points spread flat say the unit does too many things, points concentrated in one deep row say extract. That is the distinction the bare total cannot make.

Three properties of the list are worth relying on:

  • The amounts sum to Cognitive, and a test asserts it. Be precise about what that buys: the score and the breakdown are computed from the same rows, so the sum catches a row dropped or misattributed on the way to this list -- it cannot catch a construct scored wrong in the first place, because that moves both sides together. Verified both ways rather than assumed. Whether the scores themselves are right is the reference-score suite's job.
  • Rows are in source order -- by line, then left to right within a line -- so a unit reads as it is written: a one-line if ... elseif ... else lists if, elseif, else. Two rows that start at the same column, such as a ternary whose condition is a boolean run, are ordered by construct name.
  • A decision-free unit gets an empty list, not a missing property. Absent and empty are different answers, and iterating the property should not require telling them apart.

Each clause of an if chain is its own row: the if with its nesting, then every elseif at its condition's line and the else at its block's, each +1. A chain therefore shows which branch costs what rather than landing on the if line as one lump.

Contributions covers the cognitive score only; cyclomatic is a count of decision points and its total is already its own explanation.

How long a unit is

Complexity says nothing about size: a 60-line function of straight-line code scores 1 and 0. -Detailed therefore also adds LinesOfCode, counted exactly as SonarQube counts ncloc: a physical line holding at least one character that is neither whitespace nor part of a comment.

  • Counted: a line holding only a brace, every line of a statement continued over several lines, a line holding only a continuation backtick, and the non-blank lines inside a here-string.
  • Not counted: blank lines, comment lines, comment-based help, and the comment after code on a line (the line counts once, for its code).
  • Attributed to the innermost unit, as every other number on a record is: a nested function's lines are its own and not its parent's, a class's own lines belong to the script body, and a line holding code of two units -- } function Next { -- counts for each.

It is information only, with no threshold. SonarQube's definition rather than one of our own, because it is the one most readers already know and PowerShell has no agreed alternative.

Nothing changes without the switch. The six fields above are what a default run emits, in that order, and CI consumers that parse it are unaffected.

Disagreeing with a number

Every complexity gate eventually meets a unit that is genuinely, irreducibly complex -- a parser, a dispatch table, a state machine -- where the honest answer is "yes, and here is why that is correct". Without a way to say so the only options are lowering the ceiling for everyone or not measuring the file, and the second is indistinguishable from never having looked.

-Accept is the third answer. Each entry names one unit and carries the argument for it:

$accept = @(
    @{ File   = 'src/Parser.ps1'
       Unit   = 'Read-Token'
       Reason = 'table-driven lexer; splitting the table across helpers hides it' }
)

if (-not (Test-PSComplexity ./src -Recurse -Accept $accept)) { throw 'Complexity gate failed' }

It is a checkable claim, not a suppression. The gate throws when an acceptance stops describing the run:

The claim What happened
no unit of that File + Unit was measured renamed, moved, or outside the path being gated
the unit is within both ceilings somebody fixed it and left the note behind
Reason is empty or whitespace a declaration with no argument is a mute button
File or Unit missing the claim does not say what it is about

That is the whole difference from a suppression list. A suppression that stops applying goes on sitting in the file, excusing nothing, and the next breach of that unit passes unnoticed -- so the list ages into a mute button nobody dares delete. An acceptance fails the build that relies on it instead, on the run where it stopped being true.

Three properties worth relying on:

  • Every fault is reported at once, not the first, so fixing a stale list costs one CI round trip rather than one per entry.
  • The key is File and Unit. A gate keyed on the symbol alone would excuse every like-named unit in the tree, which is how suppression lists usually leak.
  • An accepted unit is still measured. This is gate policy, not a measurement filter, so the unit keeps its record and its number -- a report or a baseline built on the same records still sees it. Hiding it would take the argument away along with the number it is about.

File must match the record's File exactly: relative to the working directory with forward slashes, or the full path for a file outside it.

The argument can also sit in the code, directly above the unit it excuses:

# PSCx-Accept: table-driven lexer; splitting the table across helpers hides it
function Read-Token {
    param($Text)
    ...
}

class Repo {
    # PSCx-Accept: one switch over every verb, by design
    [void] Dispatch([string]$Verb) { ... }
}

It is the same checked claim, not a suppression comment: the gate throws when the unit is back within its ceilings, or when the reason is empty, exactly as for -Accept. What it adds is that the reason moves with the code -- a renamed file or a moved function takes it along, where a File + Unit entry goes stale.

  • Directly above means in the comment lines touching the declaration, with no blank line between. It may sit anywhere in that block, so comment-based help written as line comments still works. Functions, filters, class methods, constructors and initialised properties can be accepted this way; the script body cannot, because it has no declaration to stand above -- use -Accept.
  • Whole units only. A score belongs to a unit, so there is no way to excuse one if inside it: that would pass the unit on a number nobody measured.
  • A declaration that excuses nothing is refused, not ignored -- a blank line between it and the function, code on its own line, nothing below it, or two units starting on the line below. A reason that drifted away from its function would otherwise sit there believing it excuses something.
  • A unit accepted both in its code and by -Accept or the config is refused: two arguments for one unit drift apart, and one of them silently does nothing.
  • The JSON report records each acceptance's source -- inline with its line, or list.
  • Only Test-PSComplexity applies them; Measure-PSComplexity measures every unit regardless.

Turning the gate on when you are already over the line

A repo with forty units above the ceiling has two options without a baseline, and neither improves anything: set the ceiling so high it gates nothing, or leave the check off.

-BaselineFile is the third. It is a committed JSON file recording what each already-over-the-limit unit scored, and it changes the answer to "we have forty violations" from raise the threshold to record them, then never add a forty-first.

# once, to record where you are starting from -- then commit the file
Test-PSComplexity ./src -Recurse -BaselineFile ./complexity-baseline.json -UpdateBaseline

# from then on, in CI
if (-not (Test-PSComplexity ./src -Recurse -BaselineFile ./complexity-baseline.json)) {
    throw 'Complexity gate failed'
}

Two rules, and the second is the one that makes it worth having:

  • A unit in the baseline may not exceed its recorded score. It is allowed to stay ugly.
  • A unit not in the baseline must be under the ceilings -- so new and touched code meets the real bar from day one, on the first run, with no grace period to negotiate.

The file looks like this:

{
  "schemaVersion": 1,
  "metricVersion": 2,
  "generatedAt": "2026-08-25T09:12:44Z",
  "units": [
    { "file": "src/Parser.ps1", "unit": "Read-Token", "cyclomatic": 34, "cognitive": 41 }
  ]
}

It ratchets, and it only ratchets down. -UpdateBaseline refuses to record a unit worse than the file already does, naming the units that regressed. Without that refusal, re-running the tool would absorb whatever the gate had just caught, and the baseline would be a suppression list that updates itself.

Nor does it add a unit it has never recorded, unless you say so. A unit new to the baseline is new code, which should meet the ceilings, or a renamed one -- and a rename that also got worse is exactly the regression above, under a name the file has no number for. So -UpdateBaseline refuses an existing baseline any breaching unit it has not seen, naming each with its scores. Fix it, accept it with an argument, or record it on purpose:

Test-PSComplexity ./src -Recurse -BaselineFile ./complexity-baseline.json -UpdateBaseline -AllowNew

Each unit added that way is still named in a warning, so the build log says what the diff says. Seeding a baseline, or regenerating one across a metric version change, needs no -AllowNew: there is nothing to add to.

It is a checkable claim, like an acceptance. The gate throws when an entry stops describing the run:

The entry What happened
names a unit that was not measured renamed, moved, or outside the path being gated
names a unit now within both ceilings it was fixed; the real bar covers it and the entry is fiction
records a number larger than reality it improved -- lower the entry, or -UpdateBaseline
is also in -Accept the acceptance already excuses it, so the entry permits nothing
appears twice one of the two decides what is permitted and the other silently does nothing
file or unit missing the entry does not say what it is about

The third row is the ratchet tightening, and it is deliberately a failure rather than a shrug: a baseline that keeps a number the code no longer needs has quietly stopped being a baseline.

The key is file and unit, never a line. A line number moves whenever anything above it is edited, and the entry then goes stale although the unit did not change.

One identity is refused outright. Duplicate definitions in a single file are told apart by an ordinal -- Get-Thing#1, Get-Thing#2 -- and an ordinal renumbers when a duplicate is inserted above it, so the entry would silently begin capping a different function. Rename one of the duplicates; in PowerShell the later definition shadows the earlier at run time anyway, so one of them is already dead code.

A baseline recorded against a different metricVersion is refused rather than compared. When the metric changes, old scores are not larger or smaller -- they are answers to a different question. -UpdateBaseline regenerates such a file wholesale, and the diff is where it gets reviewed.

-UpdateBaseline writes no report and no SARIF: the verdict they would record is one the call just manufactured by recording every breach.

Keeping the policy in a file

Ceilings, acceptances and a baseline can all live on the command line, which is fine for one CI step and drifts as soon as there are two -- the pipeline, a developer running the gate by hand, a pre-commit hook. A committed .pscomplexity.json holds them in one place:

{
  "$schema": "https://raw.githubusercontent.com/Fortigi/PSComplexity/main/schemas/v1/config.schema.json",
  "schemaVersion": 1,
  "maxCyclomatic": 15,
  "maxCognitive": 15,
  "exclude": ["src/generated", "**/*.g.ps1"],
  "overrides": [
    { "path": "tests", "maxCyclomatic": 25, "maxCognitive": 25 },
    { "path": "src/Parser.ps1", "maxCognitive": 30 }
  ],
  "accept": [
    { "file": "src/Lexer.ps1", "unit": "Read-Token", "reason": "table-driven lexer; splitting it hides the table" }
  ],
  "baselineFile": "complexity-baseline.json"
}
Test-PSComplexity ./src -Recurse                 # finds and applies the config
Test-PSComplexity ./src -Recurse -NoConfig       # applies none: 15 / 15
Test-PSComplexity ./src -Recurse -ConfigFile ./ci/strict.json

Which file. -ConfigFile names one. Without it, the gate takes the nearest .pscomplexity.json from each -Path up to the repository root -- the directory holding .git -- and never looks above that root, nor anywhere at all outside a repository, so a file left in a parent or home directory cannot quietly start deciding verdicts. The paths of one run must all fall under the same config, or the run is refused. The file used is printed (Using config .pscomplexity.json) and recorded as config in the JSON report, so a verdict cannot be read without the policy behind it.

What it says.

  • maxCyclomatic / maxCognitive -- the ceilings for every unit no override reaches. Default 15.
  • overrides -- other ceilings below a path: a directory or a file, no wildcards. The deepest path reaching a file decides each metric, and a metric an override leaves out comes from a shallower one or from the ceilings above.
  • exclude -- files not measured at all. * and ? stay inside one path segment, ** spans any number, and a pattern covers everything below what it names, so src/generated needs no /**. The report lists every excluded file under scope.excluded.
  • accept -- exactly what -Accept takes, with the same checks: the gate throws when an acceptance stops describing the run.
  • baselineFile -- the baseline the gate holds units to and -UpdateBaseline writes.

Every path is relative to the config file's own directory, with forward slashes, and may not leave it. Unknown keys are refused rather than ignored, because a misspelt maxCognitve would otherwise be a setting that silently does nothing; $schema and _-prefixed comment keys are allowed. A config that is not valid is refused before anything is measured, naming every fault in it.

The command line wins wherever it says something. -MaxCyclomatic and -MaxCognitive replace the config's ceilings for every unit, overrides included -- a caller typing -MaxCognitive 10 means every unit. -Accept replaces the config's acceptances and -BaselineFile its baseline.

Only Test-PSComplexity reads a config. Measure-PSComplexity measures what it is pointed at.

Gating a pull request on what it changed

A whole-tree gate answers "is this codebase clean?" when the reviewer is asking "did this PR make things worse?". On a change touching two files, the verdict is dominated by code the author did not write -- which is how thresholds get raised until they stop meaning anything.

-ChangedFile restricts the run to units in the files you name:

$changed = git diff --name-only --diff-filter=d origin/main...HEAD
if (-not (Test-PSComplexity ./src -Recurse -ChangedFile $changed)) {
    throw 'Complexity gate failed'
}

Together with -BaselineFile it covers both halves of the question: the baseline stops old code degrading, the diff scope stops new code arriving over the line.

A filtered run checks the baseline entries and acceptances for the files it read, and leaves out the ones for files it did not: it cannot tell whether those still hold. -UpdateBaseline is refused with -ChangedFile, because the update records what the run measured and would drop every entry for the rest of the tree; update the baseline from a whole-tree run.

You decide what changed, and that is deliberate. There is no -ChangedSince <ref> that shells out to git, because a diff is not a fact this module can compute. It needs a base to compare against, and every way that goes wrong goes wrong in the caller's environment: a shallow clone where origin/main was never fetched, a detached HEAD, a ref that does not exist, a merge base that is not what the reviewer sees. Resolving it here would turn those into a complexity tool refusing to run, several layers from the shell where they can actually be fixed. Running the git command yourself keeps the failure where the fix is -- and works just as well with any other VCS, or with a CI provider that already hands you the changed-file list.

An empty list is refused, and this is the guard that matters:

-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.

A git diff that fails prints nothing and exits 0. Taken at face value that is a confident pass over zero units. If your pipeline can genuinely produce "no changed files", skip the step -- that is a decision only you can make, and it should be visible in your workflow rather than inferred here.

So is a list naming PowerShell files that are not there. The list is resolved against the current directory, and git prints paths relative to the repository root -- so a gate run from a subdirectory matched nothing, measured no units, and passed. Run it from the root. A file the change deleted is refused the same way, because there is nothing left to measure; that is what --diff-filter=d in the examples is for. Only .ps1 and .psm1 entries are checked: a deleted README changes no answer.

A filtered run says so. The gate prints nothing when it passes, so a diff-scoped pass and a whole-tree pass would otherwise look identical:

WARNING: Measured 14 unit(s) from 1 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.

The JSON report records it too, under scope.changedFile -- null for a whole-tree run, an array for a filtered one. Absent and empty are different answers there: only null may be read as a measurement of everything under path.

A pull request that changes no PowerShell passes. Touching only markdown measures nothing, and that is an ordinary outcome rather than an error -- the refusal for a run that measured nothing applies to whole-tree runs, where it means the gate was pointed at the wrong place.

Gating only the changed functions

-ChangedFile gates every unit in a touched file, so a one-line fix in a legacy file surfaces every old breach in it. -ChangedDiff takes the diff itself and gates only the units owning a changed line:

$diff = git diff --unified=0 origin/main...HEAD
if (-not (Test-PSComplexity ./src -Recurse -ChangedDiff $diff)) {
    throw 'Complexity gate failed'
}
  • A changed line belongs to the innermost unit holding it, the same rule lines of code follow. Editing a nested function does not drag in its parent. A line outside every function belongs to the script body.
  • A pure deletion belongs to the unit it was deleted from, because removing a branch changes that unit. A deletion between two functions belongs to the script body.
  • Only the headers are read: +++ for the file and @@ for the new-side line range. Context lines count as changed, so use --unified=0. The module stays git-free: any tool that writes a unified diff works.
  • The same refusals as -ChangedFile. An empty diff is refused, because a failed git diff prints nothing. So is text that is not a diff (a list of file names belongs to -ChangedFile), a PowerShell file the diff names that is not there from the current directory, and giving both parameters. A diff that changes no PowerShell passes.
  • The policy still sees the whole file. Acceptances and baseline entries for the changed files are checked against every unit in them, so a stale one fails the run as it would on the whole tree. -UpdateBaseline is refused with either parameter.
  • It says so. The warning reads Gated 2 unit(s) owning lines changed in 1 file(s), not the whole tree., and the JSON report records the hunks under scope.changedLines, beside the files in scope.changedFile.

A report something else can read

Objects are right for a shell and awkward for everything else. -ReportPath writes the same run as JSON, described by schemas/v1/report.schema.json, which ships with the module so a consumer can validate a report without reading this repo's tests.

Measure-PSComplexity ./src -Recurse -ReportPath ./reports/complexity.json
Test-PSComplexity   ./src -Recurse -ReportPath ./reports/gate.json -SarifPath ./reports/gate.sarif

The record stream is unchanged -- the report is written alongside it, never instead of it.

Two shapes, and the difference is load-bearing. A measurement report carries the units, the scope that was asked for, the files that were skipped and why, a summary, and the metric version that produced the numbers. A gate report adds the ceilings that applied, the verdict, the units that breached, and every acceptance with its argument.

A measurement report cannot carry a verdict, and the schema is what makes that true rather than a promise: passed is forbidden unless thresholds are present. Measure-PSComplexity applies no ceilings, so a verdict beside its numbers would be an answer nobody computed.

Three fields are required for the same reason the summary exists at all:

Required Because
metricVersion the metric has moved twice for source that did not change; a stored number that cannot be checked for comparability is a trend chart waiting to mislead
scope an aggregate that cannot say what it covered is not an aggregate anyone can act on
skipped a report that omits what it could not read describes a smaller job than the one it was asked to do

schemaVersion changes when a field changes meaning or disappears, never when one is added -- so the schema permits properties it has never seen, and a consumer validating against it survives a release that records more.

Inline on a pull request

-SarifPath writes a SARIF 2.1.0 log, which GitHub code scanning renders against the diff:

- name: Complexity gate
  shell: pwsh
  run: |
      $ok = Test-PSComplexity ./src -Recurse -SarifPath ./complexity.sarif
      if (-not $ok) { Write-Host '::warning::complexity gate failed' }
- uses: github/codeql-action/upload-sarif@v3
  with:
      sarif_file: ./complexity.sarif

One result per breached ceiling, under two rule ids -- PSCxCyclomatic and PSCxCognitive -- so a unit over both produces two, and a team can suppress one metric without silencing the other. Findings are fingerprinted on file and unit, never on the line: a line moves whenever anything above the unit is edited, and a fingerprint built on it would close and reopen the same finding on an unrelated change.

An accepted unit produces no SARIF result. The gate excused it, and asking a reviewer to act on something already argued is noise. The argument is not lost -- the JSON report carries it under accepted, which is where "what did this run excuse" is answered.

The same file works in Azure DevOps -- see Azure Pipelines.

Only the gate writes SARIF. Without ceilings there is no such thing as a finding, so a SARIF file from Measure-PSComplexity would be an empty results array claiming a clean bill of health.

A summary a person can read

-MarkdownPath writes a Markdown summary of the same run, for a CI step summary or a pull request comment. It holds no number the records do not -- it is rendered from the same scan:

- name: Complexity gate
  shell: pwsh
  run: Test-PSComplexity ./src -Recurse -MarkdownPath $env:GITHUB_STEP_SUMMARY -CompareTo ./main-report.json
  • The headline -- the gate's verdict and the ceilings it applied, or what was measured -- and, beside it, what the numbers do not cover: a diff-scoped run, excluded or unreadable files, the config that set the policy, and how many units were accepted or held to a baseline.
  • Every unit over a ceiling (the gate only), each with the ceiling it faced. Never cut to the top N: a summary showing ten of twelve breaches would read as a smaller problem than it is.
  • The most complex units, by cognitive then cyclomatic. Ten by default; -MarkdownTop sets it.
  • Per-file totals -- units, summed cognitive, and the highest score of each metric.
  • With -CompareTo, a previous JSON report (-ReportPath), for example one saved from the main branch: the units that are new, worse or better than in it, matched by file and unit name. A report from another metric version is refused, because its numbers answer a different question.

The file is replaced, not appended to; each GitHub Actions step gets its own summary file, so $env:GITHUB_STEP_SUMMARY works. Both commands write it. The gate writes it only when it reaches a verdict, and -UpdateBaseline writes none.

In the editor, while you write

The package ships a PSScriptAnalyzer rule, so VS Code (through the PowerShell extension) marks a function over its ceiling as you type, not only when the gate runs. Point CustomRulePath at the rule file inside the installed module:

$rule = Join-Path (Get-Module PSComplexity -ListAvailable)[0].ModuleBase 'rules/PSComplexity.Rules.psm1'
Invoke-ScriptAnalyzer -Path ./src -Recurse -IncludeDefaultRules -CustomRulePath $rule

For the editor, put the same path in the PSScriptAnalyzerSettings.psd1 the extension reads (CustomRulePath = '<that path>', with IncludeDefaultRules = $true to keep the built-in rules). Name the file itself: PSScriptAnalyzer does not find the rule from its directory.

  • The gate's verdict, not an approximation. The rule calls the module's own scoring, from the same installed version, and applies the same policy as Test-PSComplexity with nothing on the command line: the .pscomplexity.json the file sits under, or 15 and 15; its per-path ceilings and excludes; acceptances in the config and in the code; units held by its baseline. A unit warns exactly when the gate would fail it.
  • What you see is what you typed. The editor's text is measured, not the file on disk. An unsaved buffer has no path, so it gets the default ceilings.
  • Severity Warning, on the first line of the unit's declaration, naming both scores against the ceilings.
  • It does not refuse. The gate throws on a stale acceptance or baseline entry; an editor showing one file has not seen the run, so it applies what it can and leaves that to the gate.

Use it in CI

- shell: pwsh
  run: |
    Install-Module PSComplexity -RequiredVersion 0.4.0 -Force -Scope CurrentUser
    if (-not (Test-PSComplexity ./src -Recurse)) { throw 'Complexity gate failed' }

-RequiredVersion, not a floor. A gate decides whether a build passes, and this module's metric has already moved twice for unchanged source. Without an exact version two machines on the same commit can legitimately disagree about whether the build is green -- and the one that upgraded first looks like the one that broke it.

That pin is also what makes a committed baseline safe to compare against. A baseline records which metricVersion produced its numbers and refuses to be read across a change to it, so an unpinned upgrade turns into a clear refusal rather than a quietly wrong comparison -- but a refusal still fails the build, and pinning is how you choose when to deal with it.

On a codebase already over the ceilings, add the baseline and nothing else changes about the above:

    if (-not (Test-PSComplexity ./src -Recurse -BaselineFile ./complexity-baseline.json)) {
        throw 'Complexity gate failed'
    }

Azure Pipelines

The same gate runs unchanged on Azure Pipelines; what differs is how the findings are shown. examples/azure-pipelines.yml is a complete pipeline with every step below.

- pwsh: |
    Install-Module PSComplexity -RequiredVersion 0.5.2 -Force -Scope CurrentUser
    New-Item -ItemType Directory -Force '$(Build.ArtifactStagingDirectory)/complexity' | Out-Null
    $ok = Test-PSComplexity ./src -Recurse `
            -ReportPath '$(Build.ArtifactStagingDirectory)/complexity/complexity.json' `
            -SarifPath  '$(Build.ArtifactStagingDirectory)/complexity/complexity.sarif'
    if (-not $ok) { Write-Host '##vso[task.logissue type=error]Complexity gate failed'; exit 1 }
  displayName: Complexity gate
- task: AdvancedSecurity-Publish@1
  condition: succeededOrFailed()
  inputs:
    SarifsInputDirectory: '$(Build.ArtifactStagingDirectory)/complexity'
    Category: 'complexity'

Run it from the repository root. Every File -- in the records, the report and the SARIF -- is relative to the working directory, and Azure DevOps matches a finding to a file by that path. Run from anywhere else and the alerts point at files that do not exist.

Two ways to see the SARIF, from the same file:

  • GitHub Advanced Security for Azure DevOps turns each breach into a code scanning alert on the repository, through AdvancedSecurity-Publish@1. Set Category. The log deliberately carries no category of its own: on GitHub a category written into the file overrides the one the upload step names, so a fixed one would make two PSComplexity uploads in one repository replace each other. The pipeline names it, on both platforms.
  • Without Advanced Security, install the SARIF SAST Scans Tab extension from the Marketplace and publish the SARIF as a pipeline artifact named exactly CodeAnalysisLogs. Each run then gets a Scans tab listing the breaches. That is per run, not per repository: there is no alert history and nothing is closed when a unit is fixed.

Either way, the publishing step needs condition: succeededOrFailed(). The gate failing is exactly when there are findings, and a step that runs only on success publishes them on every build except the ones that have any.

Azure Repos has no equivalent of GitHub's inline annotations on a pull request diff, with or without SARIF. What reaches the PR is the build result and its log, where each breach is already printed as a warning naming the unit -- which is why the module writes no ##vso[...] commands of its own: it would be CI-specific output saying what the warnings already say.

Gating a pull request on what it changed. Azure Pipelines checks out with a depth of 1 by default and builds a detached merge commit, so the target branch a diff needs is not there. Fetch full history and diff against the target branch the pipeline names:

- checkout: self
  fetchDepth: 0
- pwsh: |
    $target  = '$(System.PullRequest.TargetBranch)' -replace '^refs/heads/', ''
    $changed = git diff --name-only --diff-filter=d "origin/$target...HEAD"
    if (-not (Test-PSComplexity ./src -Recurse -ChangedFile $changed)) { exit 1 }
  condition: eq(variables['Build.Reason'], 'PullRequest')

If the diff fails it prints nothing, and an empty -ChangedFile is refused rather than read as a pass -- so a broken checkout fails the build instead of approving it.

Development

Invoke-Pester ./tests   # reference-score tests + end-to-end + self-complexity gate
Invoke-ScriptAnalyzer -Path ./src -Recurse -Settings ./PSScriptAnalyzerSettings.psd1

All gates (lint, reference-score tests, self-complexity gate) run on Windows and Linux in the required CI job and block the merge.

What the number does not say

Two things follow from measuring per unit, and both are decisions rather than gaps. They are written down because a reader who discovers them from the output will reasonably assume one of them is a bug.

The gate cannot tell decomposition from displacement. A 20-line Invoke-Deploy scoring 14/38 fails at the default ceilings. Split the identical control flow into four private helpers plus an orchestrator and the maximum drops to 6/10, the gate passes, and the file's total cognitive falls from 38 to 21 -- a total that appears nowhere, because the output is per unit and the gate is a maximum over units.

Whether that split made the code easier to read is a question the tool cannot answer. Often it does; sometimes it only moves the branch somewhere else. Complexity exists per unit, so a per-unit measure is the honest one, and this is the price of it. This module does the same thing to itself and says so: Cognitive.ps1's row collectors are split into small functions partly so the metric clears its own gate, while Get-PSCxCyclomaticRow leaves the same shape inline and is the joint-highest unit here.

A related consequence worth knowing before trusting a low score: a 60-line function assigning sixty fields through a $global: accumulator scores 1/0 -- identical to function Get-Flat { $x }. Neither metric measures length, coupling or state.

A nested named function contributes nothing to its parent. The same body written as a script block contributes 3/5. That asymmetry is deliberate: a nested named function is discovered as its own unit and gets its own row, so its complexity is measured -- just not there. A script block has no unit of its own, so its complexity must land on the enclosing function or vanish.

It is also the cheaper of the two displacement routes, since a hard branch moved into a nested named function drops the parent under the ceiling without moving a line out of the file. This is a deliberate departure from SonarSource, whose specification increments for nested function declarations in languages that have them, for exactly that reason. Recorded here rather than silently: every other reference score in this module matches the spec, and the ones that do are attributed in the test suite.

Notes / scope

  • PowerShell 7.0+ only (Core). Windows PowerShell 5.1 is not supported -- the metric scores &&, ||, ??, ??= and the ternary, and the AST types for those arrived with the operators themselves in 7.0. Their type names are referenced directly, and an unresolvable type literal is a parse-time failure, so an older host cannot load the module at all rather than degrading.
  • Cognitive complexity is computed via ancestor-based nesting analysis; it reproduces the SonarSource reference scores. Mutual (indirect) recursion is not counted — only direct self-recursion, matching the common implementation. Recursion lists the forms recognised.
  • Some files cannot be parsed on some hosts, and are skipped with that reason rather than as a syntax error. A workflow cannot be parsed by PowerShell 7 at all. A DSC Configuration cannot be parsed where the DSC schema store is missing, which is Linux by default; Windows parses it. Either way the gate refuses the run, because it cannot vouch for a file it did not read. Gate DSC configurations on Windows, or leave such files out of the path you gate on.
  • Hidden files and dot-directories are measured, on every OS. .github/scripts/x.ps1 is code like any other. Before 0.6.0, Linux skipped dot-names silently while Windows measured them, so one tree gave two answers.

PowerShell-specific increments

The SonarSource specification predates these constructs or was written for languages that lack them, so it says nothing about how to score them. Each is scored by the rule it most resembles, and the choice is stated here rather than left to be discovered from a number:

Construct Scored as Why
ForEach-Object, % a loop It is PowerShell's second loop. A body written with it branches exactly as much as the foreach form, and scoring it lower rewarded a rewrite that changes nothing a reader would call a decomposition.
Where-Object, ? a conditional Same argument: it is a filter, and a filter is a branch.
.ForEach(), .Where() a loop, a conditional The same two cmdlets written as the methods every object carries. They take a script block exactly as the cmdlets do, so scoring them lower made the method a cheaper spelling of the same logic. Instance calls only -- the intrinsics are never static.
&&, || a boolean run They are control flow between pipelines, not boolean operators — but a run of them costs 1, exactly as -and/-or does, so the shell idiom is never dearer than the expression it mirrors.
??, ??= a ternary A choice between two values on a null test, which is what the ternary rule already covers.

foreach and where are recognised as aliases of the two cmdlets. The keyword forms parse to different AST nodes entirely, so there is no ambiguity.

If you need the specification's numbers exactly and nothing more, pin 0.2.0; every score here is greater than or equal to the one it produced.

Recursion

The specification counts a method calling itself. A static walk cannot resolve what a call runs, so recursion is recognised by the spelling of the call, and the line is drawn here rather than left to be discovered from a number. Each recognised call is +1, with no nesting.

Unit Recognised as calling itself
A function its own name, with or without a module qualifier (MyMod\Get-Fact) or a scope modifier (script:Get-Fact); & ${function:Get-Fact}; & $MyInvocation.MyCommand and & $MyInvocation.MyCommand.Name
A class method $this.Method(), or [Class]::Method() for a static one
A script file (<script-body>) & $PSCommandPath; & $MyInvocation.MyCommand and & $MyInvocation.MyCommand.Path; its own file name as .\Name.ps1 or $PSScriptRoot\Name.ps1, either separator, quoted or not

A script calling itself is an extension, as the constructs above are: the specification's unit is a method, and here a file is a unit too. .\Name.ps1 is relative to the current location rather than the script's folder, so it is the same file only by convention -- the one a script written to call itself follows.

Not counted, deliberately:

  • A path to anything else, including the function's own name as a path (.\Get-Fact): a path runs a script, not the function.
  • A command held in a variable (& $cmd), or run through Invoke-Command: what they run is a value, not a spelling.
  • $MyInvocation inside a script block. & { & $MyInvocation.MyCommand } runs the block, which has an invocation of its own; a block handed to ForEach-Object shares its caller's, but a static walk cannot tell which command a block will be given, so none is counted.
  • $PSCommandPath inside a function. It runs the script, not the function, and that is mutual recursion.
  • Mutual recursion of any kind.
  • & $function:Get-Fact. It is not a self-call at all: it parses as $function:Get followed by the text -Fact, and fails when run. A hyphenated name needs the braced form.

Constructs inside a condition

A construct written in a structure's condition pays that structure's nesting, exactly as one in its body does. if ($x ? 1 : 2) { } scores the ternary +2, not +1, and so does while ($a ?? $b) { }, a foreach over a ternary, or a Where-Object in an if condition.

This is a decision, not an oversight, and it is stated here so it is not reported as a bug. The specification lists what raises nesting (B2) and charges a structure nested inside one (B3), but never separates a condition from a body -- and SonarSource's own implementations disagree:

Implementation Construct in an if condition In a loop or ternary condition
SonarC# -- CSharpCognitiveComplexityMetric.cs pays the nesting pays the nesting
SonarJava -- CognitiveComplexityVisitor.java does not pays the nesting
PSComplexity pays the nesting pays the nesting

SonarC# visits each whole node, condition included, inside VisitWithNesting; SonarJava scans an if condition before raising nesting, but raises it before visiting a loop or a ternary. Checked against sonar-dotnet a73145b and sonar-java 5778942. PSComplexity matches SonarC# everywhere, and SonarJava everywhere except an if condition: one rule for every structure rather than an exception for one of them.

License

MIT © Fortigi

About

Cyclomatic + cognitive complexity for PowerShell - faithful SonarSource metric, AST-based, with a CI gate.

Resources

Stars

2 stars

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages