PSMutant.psd1

@{
    RootModule        = 'PSMutant.psm1'
    ModuleVersion     = '0.6.0'
    GUID              = '9c19f399-e58d-4087-829a-22e5a7ec3282'
    Author            = 'Fortigi'
    CompanyName       = 'Fortigi'
    Copyright         = '(c) Fortigi. MIT licensed.'
    Description       = 'Mutation testing for PowerShell. Injects small faults (flip -eq to -ne, $true to $false, N to N+1, drop -not) into your scripts using the PowerShell AST and reports how many your Pester suite catches - the metric line coverage cannot give you. Runs mutants in a throwaway sandbox so your source is never modified. Requires Pester 5.2.0 or later AT RUN TIME, and deliberately does not declare it as a RequiredModule: PSMutant runs under whichever Pester >= 5.2.0 you have loaded rather than importing one for you. Install Pester yourself if you do not already have it.'
    PowerShellVersion = '7.0'

    # One function. Get-PSMutationCandidate and Set-PSMutationText used to be exported too,
    # and between them they trafficked a nine-field [pscustomobject] that nothing declared,
    # tested as a contract or versioned -- discoverable only by running the function and
    # inspecting the output, and unchangeable once someone had. Neither was ever mentioned in
    # the README, and Set-PSMutationText had exactly one caller, inside this module (#48).
    #
    # "What would you mutate?" is a fair question to ask, and the answer should be a rendering
    # this module controls -- see #10's -ListOnly -- not a raw AST walker handing out its
    # internals.
    FunctionsToExport = @('Invoke-PSMutation')
    CmdletsToExport   = @()
    VariablesToExport = @()
    AliasesToExport   = @()

    # NO RequiredModules entry for Pester, deliberately. ModuleVersion there is a MINIMUM
    # and PowerShell satisfies it by importing the NEWEST installed version -- at import
    # time, before Assert-PSMutationPester or Get-PSMutationPesterPath can have a say. That
    # made `Import-Module PSMutant` followed by `Import-Module Pester -RequiredVersion 5.7.1`
    # fail on an assembly collision and leave the caller on 6.1.0, while the same two lines
    # in the other order worked -- issue #16's failure one layer up, with no diagnostic.
    #
    # Pester is needed at RUN time, not import time, and Assert-PSMutationPester is the single
    # point that enforces it: it accepts an already-loaded Pester >= 5, imports one only when
    # none is loaded, and refuses with an actionable message otherwise. The cost is that
    # Install-Module PSMutant no longer pulls Pester in for you; that is stated in the
    # description, the README and the error message.

    PrivateData = @{
        PSData = @{
            Tags         = @('mutation-testing', 'testing', 'pester', 'ast', 'quality', 'test-quality', 'coverage')
            LicenseUri   = 'https://github.com/Fortigi/PSMutant/blob/main/LICENSE'
            ProjectUri   = 'https://github.com/Fortigi/PSMutant'
            ReleaseNotes = '**Mutants on the later lines of a multi-line statement are evaluated now, and your score may go
down.** `coveredLinesOnly` keeps a mutant only on a line the baseline executed, and a line used to
count as executed only when a command STARTED on it. So the second line of a condition split over
two lines, a continued argument list or a message built from several strings was never covered,
although the statement ran, and every mutant there was dropped from the score in silence while
the coverage gate reported 100% over the same code.

A line is now covered when the innermost command spanning it ran. That is narrower than "any
command spanning it": a pipeline that ran does not cover the body of a script block that never
executed, so those mutants are still skipped rather than handed to the loop to survive. Checked
against a real Pester coverage run, not only against hand-built records.

**What to expect.** `skippedAsUncovered` falls, and the mutants it used to hide are evaluated.
Any that survive are real gaps: a comparison on the second line of a condition, say, that no test
pins. The same tests can therefore score lower than before, so a `thresholds.break` gate can go
red on upgrade. That is the gate measuring code it used to skip, not the code getting worse.

A `param()` default is still never covered: no command spans it, so nothing Pester instruments
can say whether it ran.

**A SARIF log of the survivors, with `"sarifPath"`.** Set it and a run also writes a SARIF 2.1.0
log, which GitHub code scanning and Azure DevOps Advanced Security turn into alerts that open,
persist and close across runs:

```json
{ "reportPath": "reports/ps-mutation.json", "sarifPath": "reports/ps-mutation.sarif" }
```

One rule per operator the run applied, `warning` level, and no alert for a declared equivalent.
Each alert is fingerprinted by `file:function:description` -- the address an equivalence declaration
uses -- never by mutant id or line, so an unrelated edit above a survivor does not close and reopen
it. A run with no survivors writes a log with no results, which is what closes old alerts.

Only a run that scored writes one. A `-ChangedFile` run writes `<name>.changed.sarif` beside the
configured file, never over it, and a `-RecheckFrom` run, a `-ListOnly` preview and an interrupted
run write none: a code-scanning service reads a missing result as a fixed one, so a partial log
would close every alert it did not re-examine. The log names no category of its own -- on GitHub a
category in the file overrides the upload step''s -- so give one in the pipeline (`category:` on
upload-sarif, `Category:` on `AdvancedSecurity-Publish@1`).

Checked with the SARIF validator''s GitHub Advanced Security and Azure DevOps rule sets: it passes
both, apart from the category, which is left to the pipeline on purpose.

**The stale-sandbox sweep no longer deletes a live run''s files on Windows.** At startup a run
reclaims sandboxes and coverage files whose owning process is gone, and treats an id that has been
reused as gone too: an owner that started after the file was made cannot have made it. That test
read the file''s CREATION time, and two things on Windows can make a live owner''s fresh file look
older than its owner -- a file stamp is only as fine as the timer tick, and NTFS hands a name that
was deleted and recreated moments later its old creation time. The sweep now reads the last WRITE
time, with two seconds of slack, so a concurrent run''s working files are left alone. Real id reuse
leaves a far larger gap, so leftovers are still reclaimed.

**Survivors are annotated under Azure Pipelines too.** Under GitHub Actions a survivor has always
been printed as a `::warning` workflow command. Under Azure Pipelines (`TF_BUILD=True`) it is now
a `##vso[task.logissue type=warning;...]` command, which lists it in the build summary linked to the
line. As before, `-Quiet` does not silence it, and nothing is printed outside a CI.

New in the README: how to get survivors onto a pull request, on both platforms, and
`examples/azure-pipelines.yml`, a complete Azure pipeline -- a full run on `main`, a `-ChangedFile`
run on pull requests, and both ways of publishing the log.

The SARIF log and the Azure annotations move no score and change no existing output. The config
gains one optional key.

**A red baseline says where to look when no failing test said why.** The refusal names the failing
tests and the first line of each one''s error. When NONE of them carried an error -- the shape of a
`BeforeAll` that died, whose error Pester attaches to the test file, or of a damaged Pester install
that fails even a test with no assertion -- the names alone point nowhere, so the message now says
so and names both places to look.'

        }
    }
}