Skip to content

[Feature]: Gate complexity and test strength with PSComplexity and PSMutant (PR on changed files, weekly on the whole tree) #91

Description

@mvaneijken

Problem statement

The build already fails on any PSScriptAnalyzer Error/Warning, imports the built module under both powershell.exe and pwsh.exe with Set-StrictMode -Version Latest, enforces comment-based help and runs ~1,165 Pester test cases. None of that measures:

  • whether a test would catch a bug. A passing suite proves the code ran. It does not tell a test that asserts real behaviour from one that keeps passing when the condition under it is inverted.
  • whether a function has become too tangled to reason about. There is no complexity measurement at all.

Two Fortigi modules answer exactly those questions:

Module Version (pinned) Answers
PSComplexity 0.5.1, latest on the PowerShell Gallery Cyclomatic and SonarSource cognitive complexity per unit, from the AST, with a CI gate, a ratcheting baseline and -ChangedFile scoping.
PSMutant 0.5.0, latest on the PowerShell Gallery Mutation testing: injects small faults (-eq→-ne, $true→$false, a dropped -not, N→N+1) in a sandbox and checks that Pester fails. -ChangedFile scopes a run to a PR.
Pester 6.2.0 Test runner. Still ships a net462 build and declares PowerShell 5.1 as its minimum, so the Windows PowerShell leg keeps working.

Versions checked 2026-10-10. PSComplexity's main already carries a 0.5.2 (SARIF fields for Azure DevOps, no score change), but it is not tagged or published yet, so 0.5.1 stays the pin.

Both modules require PowerShell 7.0+. Every new task skips itself under Windows PowerShell 5.1, so the powershell leg of PR validation is unaffected.

Proposed solution

The bar is enforced, not report-only (this replaces the earlier report-only plan). It is applied at two scopes:

Scope When On failure
PR gate Only the files the PR changed /validate, pwsh leg, after the tests Validation fails
Weekly gate The whole tree Monday 02:23 UTC, plus manual dispatch A bug labelled quality-gate is filed per gate; later failing runs comment on it; it closes itself when the gate passes again

The bar.

  • Complexity: every unit ≤ 15 cyclomatic and ≤ 15 cognitive. The 32 units already over that are recorded in a committed complexity-baseline.json. A recorded unit may not get worse, any other unit must meet the ceilings, and an entry that stops describing the code (improved, fixed, renamed) fails until it is lowered. The baseline ratchets down only.
  • Mutation: score ≥ thresholds.break in psmutant.config.json, set at 75% from the first measurement (see the analysis comment). Only ever raised.

Phase 0: pin the toolchain, one module list

  • Build/InstallModules.ps1 is the only module list; Build/build.ps1 calls it instead of keeping its own.
  • Pester 6.2.0, PSComplexity 0.5.1 and PSMutant 0.5.0 pinned with -RequiredVersion. build.ps1 imports the pinned Pester explicitly, because 5.1 ships Pester 3.4.0 inbox.
  • Pester 6.2.0 checked against the suite: run against the source module on Linux, Pester 5.7.1 and 6.2.0 produce the identical set of passed and failed tests (940 passed; the 176 failures are all Windows-only APIs).
  • psake, PSDeploy, PSScriptAnalyzer and ThreadJob are still unpinned or floor-only. Pinning them needs the current gallery versions confirmed; left as a follow-up.

Phase 1: complexity gate

  • Task Complexity: whole tree, against complexity-baseline.json, JSON report and SARIF under buildoutput/quality/.
  • Task ComplexityChanged: changed files only.
  • Workaround for an upstream defect: PSComplexity 0.5.1 (and current main) throws when -ChangedFile and -BaselineFile are combined. Every baseline entry outside the changed files reads as "renamed or moved". The task narrows the baseline to the changed files first, so the ratchet still holds for the code under review. This should be reported upstream in Fortigi/PSComplexity.

Phase 2: mutation gate

The original blocker, mutants being invisible because the tests load the built artifact, does not apply to PSMutant's runs. PSMutant invokes the mapped test files without -Data, so each file falls back to its default -ModulePath, which is the source OmadaWeb.PS.psm1, and that file already dot-sources Public/ and Private/. A mutant spliced into a source file inside the sandbox is exactly what the tests load.

  • psmutant.config.json at the repo root. The mutate → tests map is generated by Build/Update-MutationConfig.ps1: a source file is covered by every test file under Tests/Unit and Tests/Integration that names one of its functions. Tests/E2E is never mapped. Files no test names go under _untested and are not mutated, so they cannot inflate or deflate the score. -Check runs on every PR, so the map cannot go stale.
  • workers: 1, deliberately. Three suites share process-wide state that parallel runspaces would race on, and those races score as kills: StrictMode.Tests.ps1 writes $Env:OMADAWEBPS_STRICTMODE, Install-EdgeDriver.Tests.ps1 writes $Env:TEMP/$Env:TMP, and the integration tests bind fixed port 9080.
  • Task Mutate for the whole tree and Task MutateChanged for changed files. Both run under OMADAWEBPS_STRICTMODE=1, like the Test task.

Phase 3: pipelines

  • pr-validation.yml: new step ./Build/build.ps1 -Task QualityChanged on the pwsh leg (MutationConfigCheck, ComplexityChanged, MutateChanged). It reads the existing PR_CHANGED_FILES, which was previously written and never read. Each gate skips itself, saying so, when nothing it measures changed. Reports are uploaded as the QualityReports artifact.
  • quality-weekly.yml: a Complexity job and a Mutate job in parallel on windows-latest. Each writes a verdict file. An alert job (the only one with issues: write) opens, comments on or closes one bug per gate. A gate that never reached a verdict (install failure, red baseline suite) turns the run red but files no bug, because a run that measured nothing says nothing about the code.
  • CONTRIBUTING.md documents the gates, the generated map and the baseline workflow.

Open points and risks

  1. Not yet run on Windows. Everything above was exercised on Linux pwsh 7.6 with a patched copy of the module, because the module refuses to load off Windows. 60 of the 96 mapped files could be evaluated there. The other 36 depend on Windows-only tests (Authenticode, CIM, WinForms, pwsh.exe, HttpListener). Before relying on the PR gate, run it once on a throwaway PR that touches a module file, and run the weekly workflow by hand after merge.
  2. Source-mode baseline. PSMutant refuses to run unless the mapped tests are green against the source module. The earlier version of this issue noted two snippet tests that fail in source mode. If they still do on Windows, MutateChanged blocks any PR touching the files they cover, and the weekly Mutate reaches no verdict. Fix or re-map them.
  3. File-level, not line-level. Both tools judge a touched file as a whole. A one-line change to a weak file (for example Clear-OmadaWebCache.ps1 at 45.5%) has to bring that file to the floor. That is intentional (boy-scout rule), and it is the cost of this design.
  4. Floor calibration. 75% comes from the Linux-evaluable subset (78.8%). Recalibrate thresholds.break from the first full Windows run. Only raise it.
  5. Runtime. On Linux the 546 mutants took 6 minutes serially. The full Windows set includes integration suites and will be slower. The weekly job has the 360-minute ceiling. A PR touching Invoke-OmadaRequest.ps1 runs 18 covering suites per mutant and will be the slowest PR case.

Out of scope / follow-ups

Implementation: branch ccr-5a55522e-x59sy1.

Activity

  1. mvaneijken commented on Sep 11, 2026

    @mvaneijken
    CollaboratorAuthor

    Sibling proposal: Fortigi/OmadaSqlTroubleshooter#109 — same three phases, same pinned versions. Phases 0 and 1 are near-identical and should be ported in whichever direction lands first, as the workflows were in dd90afa. Phase 2 is where the two repos genuinely diverge: that repo's tests dot-source individual source files, which is exactly the per-file scoping PSMutant wants; here the build-artifact test contract has to be resolved first.

  2. changed the title [-][Feature]: Measure complexity and test strength in the build with PSComplexity and PSMutant (report-only)[/-] [+][Feature]: Gate complexity and test strength with PSComplexity and PSMutant (PR on changed files, weekly on the whole tree)[/+] on Oct 10, 2026
  3. mvaneijken commented on Oct 10, 2026

    @mvaneijken
    CollaboratorAuthor

    First analysis — main @ 92ee2f6, PSComplexity 0.5.1, PSMutant 0.5.0, Pester 6.2.0

    Measured on Linux, pwsh 7.6.6. PSComplexity reads the AST only, so its numbers are platform-independent. PSMutant has to run the tests, and the module only loads on Windows, so mutation was measured on a patched copy with the Windows environment faked, and only over the files whose covering tests are green there. Treat the mutation score as a partial, indicative reading, not the project's score. The first weekly run on Windows is the real baseline.

    Complexity (whole module, 218 units)

    Units
    Over 15 cyclomatic 25
    Over 15 cognitive 32
    Over either ceiling (now in complexity-baseline.json) 32 (15%)
    Cognitive 0–5 / 6–15 / 16–30 / 31–60 / >60 161 / 25 / 12 / 12 / 8

    Worst ten (cyclomatic / cognitive):

    Unit Cyc Cog
    Invoke-OmadaRequest 108 164
    Get-DataFromWebDriver 73 124
    Initialize-WebView2 40 115
    Invoke-WebView2MicrosoftLogin 59 105
    OmadaWeb.PS.psm1 <script-body> 52 83
    Start-WebView2Login 36 67
    Get-WebView2Cookie 28 67
    Resolve-EntraSignInScreen 50 65
    ConvertTo-RedactedLogValue 38 52
    Install-WebView2 26 52

    The debt is concentrated in the request pipeline and the browser sign-in flows: 8 units above cognitive 60, against a median of ≈2. Invoke-OmadaRequest alone is about 10× the ceiling and is the obvious refactoring target. It is also the function the most test files cover (18), so a refactor there is well protected.

    Test strength (mutation)

    Mapping: 96 source files are named by at least one test and are mapped. 11 are named by none, so they are not mutated: Close-EdgeDriver, Confirm-FileHash, ConvertTo-OmadaExpiryMoment, Get-EdgeProfile, Get-OmadaLegacyCookieCacheFile, Get-OmadaResponseStatusCode, Install-LockedPackageGroup, Reset-Timer, Start-EdgeDriver, Start-EdgeDriverLogin, Test-OmadaTransientFailure. Some of these, for example Test-OmadaTransientFailure and Confirm-FileHash, are security- or retry-relevant and are only exercised indirectly.

    Evaluated subset: 60 of the 96 files. The other 36 are covered by tests that need Windows (Authenticode, CIM, WinForms, pwsh.exe, HttpListener), and PSMutant refuses to run on a red baseline.

    Mutants produced → evaluated (coveredLinesOnly) 776 → 546
    Score 78.8% (430 killed / 116 survived), 1 timeout
    filesWithNoCandidate (vacuous 100%) 9 — mostly thin wrappers and JavaScript-snippet builders the default operators cannot touch
    filesWithNoMutants (coverage removed every candidate) 7, including Initialize-Webview2 (92 candidates, 0 covered), Get-WebView2Cookie (29, 0 covered) and Get-NuGetLibraryFolder
    Runtime 6m14s serial; the baseline run is 36s; the per-mutant timeout is 143s

    Weakest files (score, survived/total):

    File Score
    Invoke-WindowsAuthentication.ps1, Unprotect-OmadaSessionPayload.ps1 0% (1/1 each)
    Install-Selenium.ps1 25% (3/4)
    Get-InstalledModuleInfo.ps1, Reset-LoginAutomationState.ps1 40% (3/5)
    Clear-OmadaWebCache.ps1 45.5% (6/11)
    Invoke-WebEdgeDriverFramework.ps1 50% (3/6)
    Stop-OmadaLogin.ps1 55.6% (8/18)
    New-OmadaWebCacheItem.ps1 56.2% (7/16)
    Resolve-OmadaNativeCommand.ps1 57.1% (3/7)
    Select-EntraMfaMethod.ps1 66.7% (10/30)
    ConvertTo-RedactedLogString.ps1 69.4% (11/36)

    Patterns in the survivors:

    • Unprotect-OmadaSessionPayload.ps1:53 -ne → -eq survives. That condition guards ZeroFreeBSTR on the decrypted plaintext in finally. Inverted, the plaintext stays in unmanaged memory, and no test notices. This is secret-handling hygiene, not functional behaviour, so it may need a targeted test (assert the zero-and-free is called) rather than a behavioural one.
    • Clear-OmadaWebCache.ps1:112/139. if ($Force -and -not $WhatIfPreference) can be inverted unnoticed, which means no test asserts that -Force suppresses confirmation without disabling -WhatIf. The count condition at line 139 survives too.
    • Stop-OmadaLogin.ps1:136–183. Every -not [string]::IsNullOrWhiteSpace(...) around the error-detail fields survives. The tests do not assert which fields appear in the message.
    • Select-EntraMfaMethod.ps1:61–79. All priority constants (10…70, 999) survive N → N+1. These are likely equivalent mutants (only the order matters), so they are candidates for equivalents with a reason rather than for more tests.
    • ConvertTo-RedactedLogString.ps1, 11 survivors. That is significant for a redaction function, where a missed branch means a secret in a log.

    Pester 6.2.0

    Against the source module on Linux, Pester 5.7.1 and 6.2.0 produce identical results: 1,165 tests, 940 passed, 176 failed (all Windows-only APIs), 29 skipped, and a byte-identical list of failing tests. The test files use no Pester-5-only API (Assert-MockCalled and similar). On Windows the /validate run on the implementation branch is the confirmation.

    Findings outside the two metrics

    • Parallel mutation is unsafe as the suite stands (process-wide env writes and a fixed port, listed in the issue body). That is why the config runs workers: 1.
    • PSComplexity defect: -ChangedFile combined with -BaselineFile throws on every baseline entry outside the changed files, contradicting its README. Reproduced on 0.5.1 and current main; worked around in ComplexityChanged.

    Conclusion

    Complexity is fine for 85% of units, with a hard core of about 8 very large functions in the request and sign-in paths. On the parts that could be measured, the tests catch about 4 in 5 injected faults. The gaps sit in places that matter: plaintext cleanup after session decryption, the -Force/-WhatIf interaction in cache clearing, and log redaction. The floor is set at 75% from this reading; the first Windows weekly run should set it properly.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions