diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 705d385..5277b7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,6 @@ jobs: name: Pester (${{ matrix.os }}) runs-on: ${{ matrix.os }} - # add job-scoped permissions needed for artifact upload permissions: contents: read actions: write @@ -32,15 +31,49 @@ jobs: - name: Run Pester shell: pwsh - run: pwsh -NoProfile -File ./tools/run-tests.ps1 -CI + run: pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -CI - - name: Upload test results + - name: Upload Pester artifacts if: always() - continue-on-error: true uses: actions/upload-artifact@v6 with: - name: test-results-${{ matrix.os }} - path: artifacts/test-results.xml + name: pester-artifacts-${{ matrix.os }} + if-no-files-found: warn + path: | + artifacts/test-results.xml + artifacts/coverage.xml + + lint: + name: PSScriptAnalyzer + runs-on: ubuntu-latest + + permissions: + contents: read + actions: read + security-events: write + + steps: + - uses: actions/checkout@v6 + + - name: Run PSScriptAnalyzer + shell: pwsh + run: pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 -CI + + - name: Upload PSScriptAnalyzer artifacts + if: always() + uses: actions/upload-artifact@v6 + with: + name: psscriptanalyzer-artifacts + if-no-files-found: warn + path: | + artifacts/pssa-results.json + artifacts/pssa-results.sarif + + - name: Upload SARIF to GitHub Code Scanning + if: always() && github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: artifacts/pssa-results.sarif docs-cmdlet-reference: name: Verify cmdlet reference is up to date @@ -63,12 +96,11 @@ jobs: # Ignore if not supported in this environment } } - + # platyPS is pinned for deterministic Markdown output. # See CONTRIBUTING.md for upgrade procedure. - Install-Module -Name platyPS -RequiredVersion 0.14.2 -Scope CurrentUser -Force -AllowClobber -ErrorAction Stop - + - name: Debug platyPS version shell: pwsh run: | diff --git a/.github/workflows/issue-auto-assign.yml b/.github/workflows/issue-auto-assign.yml deleted file mode 100644 index 436a919..0000000 --- a/.github/workflows/issue-auto-assign.yml +++ /dev/null @@ -1,32 +0,0 @@ -name: Auto label and assign new issues - -on: workflow_dispatch -jobs: - label_and_assign: - if: github.event.issue.user.login != 'blindzero' - runs-on: ubuntu-latest - permissions: - issues: write - - steps: - - name: Add label and assign issue - uses: actions/github-script@v8 - with: - script: | - const issueNumber = context.payload.issue.number; - const repo = context.repo.repo; - const owner = context.repo.owner; - - await github.rest.issues.addLabels({ - owner, - repo, - issue_number: issueNumber, - labels: ["new"] - }); - - await github.rest.issues.addAssignees({ - owner, - repo, - issue_number: issueNumber, - assignees: ["blindzero"] - }); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 59dd703..b300db6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,14 +123,54 @@ Pull Requests must: A contribution is complete when: -- all tests pass (`Invoke-Pester -Path ./tests`) -- no architecture rules are violated (see `docs/01-architecture.md`) +- all tests pass (`pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1`) +- static analysis passes (`pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1`) +- no architecture rules are violated (see `docs/advanced/architecture.md`) - public APIs are documented (comment-based help for exported functions) - documentation is updated where required: - README.md (only high-level overview + pointers) - docs/ (usage/concepts/examples) - provider/step module READMEs if behavior/auth changes +## Local quality checks + +IdLE provides canonical scripts under `tools/` so you can reproduce the same checks locally that CI runs. + +### Run tests (Pester) + +Run the test suite: + +- `pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1` + +To generate CI-like artifacts (test results + coverage) under `artifacts/`: + +- `pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -CI` + +Outputs: + +- `artifacts/test-results.xml` (NUnitXml) +- `artifacts/coverage.xml` (coverage report) + +### Run static analysis (PSScriptAnalyzer) + +Run PSScriptAnalyzer using the repository settings: + +- `pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1` + +To generate CI-like artifacts under `artifacts/` (including SARIF for GitHub Code Scanning): + +- `pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 -CI` + +Outputs: + +- `artifacts/pssa-results.json` (summary) +- `artifacts/pssa-results.sarif` (SARIF) + +The rule set is defined in `PSScriptAnalyzerSettings.psd1` at the repository root. +The runner pins tool versions for deterministic CI results; update pins intentionally and document the change in the PR. + +> Note: `artifacts/` is a build output folder and should not be committed. + --- ## Generated cmdlet reference (platyPS) @@ -253,11 +293,6 @@ To fix a failing PR: Repository maintainers should configure branch protection so that required status checks include this workflow. - - - - - ## Documentation Keep docs short and linkable: @@ -269,7 +304,7 @@ Keep docs short and linkable: Key links: - Docs map: `docs/00-index.md` -- Architecture: `docs/01-architecture.md` +- Architecture: `docs/advanced/architecture.md` - Examples: `docs/02-examples.md` - Coding & in-code documentation rules: `STYLEGUIDE.md` diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..d6385ee --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,58 @@ +# PSScriptAnalyzer settings for IdentityLifecycleEngine (IdLE) +# +# This file is intentionally data-only (no script blocks, no expressions). +# It is used by CI and can also be referenced from VS Code workspace settings. +# +# Notes: +# - We explicitly list IncludeRules to keep the first rollout focused and low-noise. +# - Formatting rules are enabled to align with STYLEGUIDE.md (4 spaces, consistent whitespace). + +@{ + Severity = @('Error', 'Warning') + + IncludeRules = @( + # Naming / API hygiene + 'PSUseApprovedVerbs', + 'PSAvoidGlobalVars', + 'PSAvoidUsingCmdletAliases', + 'PSAvoidUsingPositionalParameters', + 'PSUseCorrectCasing', + + # Common correctness issues + 'PSAvoidUsingEmptyCatchBlock', + 'PSReviewUnusedParameter', + 'PSUseDeclaredVarsMoreThanAssignments', + 'PSAvoidTrailingWhitespace', + + # Security / risky constructs + 'PSAvoidUsingInvokeExpression', + 'PSAvoidUsingPlainTextForPassword', + 'PSAvoidUsingConvertToSecureStringWithPlainText', + + # Style / formatting (enabled explicitly) + 'PSUseConsistentIndentation', + 'PSUseConsistentWhitespace' + ) + + Rules = @{ + PSUseConsistentIndentation = @{ + Enable = $true + IndentationSize = 4 + PipelineIndentation = 'IncreaseIndentationForFirstPipeline' + Kind = 'space' + } + + PSUseConsistentWhitespace = @{ + Enable = $true + CheckInnerBrace = $true + CheckOpenBrace = $true + CheckOpenParen = $true + CheckOperator = $true + CheckPipe = $true + CheckPipeForRedundantWhitespace = $false + CheckSeparator = $true + CheckParameter = $false + IgnoreAssignmentOperatorInsideHashTable = $false + } + } +} diff --git a/STYLEGUIDE.md b/STYLEGUIDE.md index 4ccd8f3..efb8a2f 100644 --- a/STYLEGUIDE.md +++ b/STYLEGUIDE.md @@ -99,6 +99,20 @@ Providers: - No live system calls in unit tests - Providers require contract tests +## Quality Gates + +IdLE uses static analysis and automated tests to keep the codebase consistent and maintainable. + +- **PSScriptAnalyzer** is the required linter for PowerShell code. + - Repository policy is defined in `PSScriptAnalyzerSettings.psd1` (repo root). + - Run locally via `pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1`. + - CI publishes analyzer outputs under `artifacts/`. + - On default-branch runs, CI also uploads SARIF to GitHub Code Scanning. + +- **Pester** is the required test framework. + - Run locally via `pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1`. + - CI publishes test results and coverage under `artifacts/`. + --- ## Documentation Responsibilities diff --git a/docs/advanced/releases.md b/docs/advanced/releases.md index e69c2c0..3f77899 100644 --- a/docs/advanced/releases.md +++ b/docs/advanced/releases.md @@ -63,11 +63,19 @@ Pre-release tags do **not** publish to PowerShell Gallery. pwsh -NoProfile -File ./tools/Set-IdleModuleVersion.ps1 -TargetVersion 1.2.0 ``` -3. Run tests: +3. Run quality checks locally: - ```powershell - pwsh -NoProfile -File ./tools/run-tests.ps1 - ``` + - Pester tests: + + ```powershell + pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 + ``` + + - Static analysis (PSScriptAnalyzer): + + ```powershell + pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 + ``` 4. Commit and push the changes. 5. Open a Pull Request to `main` and wait for CI to pass. diff --git a/docs/advanced/testing.md b/docs/advanced/testing.md index c309901..67423d9 100644 --- a/docs/advanced/testing.md +++ b/docs/advanced/testing.md @@ -1,36 +1,67 @@ # Testing -IdLE is designed to be testable in isolation. +IdLE is designed to be testable in isolation. Tests should be deterministic, fast, and runnable on any machine (local or CI) without requiring live systems. + +## Running tests locally + +Use the canonical test runner: + +```powershell +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 +``` + +Enable coverage (optional): + +```powershell +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -EnableCoverage +``` + +If you want a specific coverage format: + +```powershell +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -EnableCoverage -CoverageOutputFormat Cobertura +``` ## Unit tests Unit tests should: -- use Pester -- use mock providers +- use **Pester** +- use **mock providers** - avoid live system calls +- prefer explicit, committed fixtures over writing ad-hoc temporary files ## Provider contract tests Provider contract tests verify that an implementation matches the expected contract. -They can run against: -- a mock harness -- a local test double -- a dedicated test tenant (only when explicitly intended) +They should: + +- test the *contract behavior* (inputs/outputs, error handling, capability surface) +- run against **mock/file providers** by default +- run against real providers only as an explicit, opt-in scenario (separate pipeline / environment) + +## CI artifacts + +The CI pipeline produces test artifacts under the `artifacts/` folder and uploads them. + +Expected outputs: + +- `artifacts/test-results.xml` (NUnitXml test results) +- `artifacts/coverage.xml` (code coverage report; format depends on configuration) + +## Static analysis -## Workflow validation in CI +IdLE uses **PSScriptAnalyzer** as a CI quality gate to enforce baseline style and correctness rules. -Validate workflows and step metadata in CI using a dedicated validation command. +Local run: -Principles: +```powershell +pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 +``` -- fail fast for unknown keys -- fail early for invalid references -- keep configuration data-only (no script blocks) +The analyzer uses the repository settings file: -## Tips +- `PSScriptAnalyzerSettings.psd1` (repo root) -- Prefer deterministic input fixtures -- Keep tests readable and focused -- Treat public cmdlets as stable contracts +In CI, PSScriptAnalyzer emits machine-readable artifacts under `artifacts/` (JSON and optional SARIF) and can publish SARIF findings to GitHub Code Scanning on default-branch runs. diff --git a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 index 191f8ec..13a53aa 100644 --- a/tests/ProviderContracts/EntitlementProvider.Contract.ps1 +++ b/tests/ProviderContracts/EntitlementProvider.Contract.ps1 @@ -117,8 +117,8 @@ function Invoke-IdleEntitlementProviderContractTests { [void]$script:Provider.RevokeEntitlement($id, $entitlement) $afterRevoke = @($script:Provider.ListEntitlements($id)) - ($afterGrant | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 - ($afterRevoke | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 + @($afterGrant | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 1 + @($afterRevoke | Where-Object { $_.Kind -eq $entitlement.Kind -and $_.Id -eq $entitlement.Id }).Count | Should -Be 0 # Sanity: $null is treated as empty. ($before -is [object[]]) | Should -BeTrue diff --git a/tools/Invoke-IdlePesterTests.ps1 b/tools/Invoke-IdlePesterTests.ps1 new file mode 100644 index 0000000..d37a7a1 --- /dev/null +++ b/tools/Invoke-IdlePesterTests.ps1 @@ -0,0 +1,244 @@ +<# +.SYNOPSIS +Runs IdLE Pester tests and optionally emits CI artifacts (test results + coverage). + +.DESCRIPTION +This script is the canonical entry point for running Pester in the IdLE repository. + +It is designed to be: +- deterministic (fixed artifact paths under repo root) +- CI-friendly (NUnitXml results + coverage output on demand) +- robust against different working directories (resolves paths relative to repo root) + +The script ensures Pester is available and imports it before running tests. + +.PARAMETER TestPath +Path to the tests folder. Defaults to 'tests' relative to the repository root. + +.PARAMETER CI +Enables CI mode: +- Writes NUnitXml test results to -TestResultsPath +- Enables code coverage and writes a coverage report to -CoverageOutputPath + +.PARAMETER TestResultsPath +Path to the NUnitXml test results file. Defaults to 'artifacts/test-results.xml' +relative to the repository root. + +.PARAMETER EnableCoverage +Enables code coverage when not running in -CI mode. + +.PARAMETER CoverageOutputPath +Path to the coverage report file. Defaults to 'artifacts/coverage.xml' +relative to the repository root. + +.PARAMETER CoverageOutputFormat +Coverage output format supported by Pester. + +.PARAMETER CoveragePath +One or more paths to include for coverage (e.g. 'src'). Defaults to 'src' +relative to the repository root. + +.PARAMETER PesterVersion +Pinned Pester version to use. Defaults to 5.7.1. + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -CI + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdlePesterTests.ps1 -EnableCoverage -CoverageOutputFormat Cobertura + +.OUTPUTS +None. Throws on failures and uses Pester exit codes when configured. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $TestPath = 'tests', + + [Parameter()] + [switch] $CI, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $TestResultsPath = 'artifacts/test-results.xml', + + [Parameter()] + [switch] $EnableCoverage, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $CoverageOutputPath = 'artifacts/coverage.xml', + + [Parameter()] + [ValidateSet('JaCoCo', 'Cobertura', 'CoverageGutters')] + [string] $CoverageOutputFormat = 'JaCoCo', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string[]] $CoveragePath = @('src'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [version] $PesterVersion = '5.7.1' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-IdleRepoRoot { + <# + .SYNOPSIS + Resolves the repository root path. + + .DESCRIPTION + The repo root is assumed to be the parent directory of the 'tools' folder. + This avoids relying on the current working directory. + #> + [CmdletBinding()] + param() + + return (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path +} + +function Get-IdleFullPath { + <# + .SYNOPSIS + Returns a full path for a repository-relative or absolute path. + + .DESCRIPTION + Resolve-Path fails if the path does not exist (e.g. output files). + This helper returns a normalized full path regardless of existence. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $RepoRootPath, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path -Path $RepoRootPath -ChildPath $Path)) +} + +function Initialize-Directory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Initialize-Pester { + <# + .SYNOPSIS + Initializes Pester by ensuring it is installed (pinned version) and imported. + + .DESCRIPTION + CI runners are ephemeral. When missing, we install Pester in CurrentUser scope. + We explicitly pin versions for determinism. + + IMPORTANT: + - We keep this logic self-contained and consistent across local + CI runs. + - We avoid auto-upgrading to newer versions unless the pinned version is changed in code. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [version] $RequiredVersion + ) + + $installed = Get-Module -ListAvailable -Name Pester | + Where-Object { $_.Version -eq $RequiredVersion } | + Select-Object -First 1 + + if (-not $installed) { + if (-not (Get-Command -Name Install-Module -ErrorAction SilentlyContinue)) { + throw "Pester ($RequiredVersion) is required, but Install-Module is not available. Install Pester manually and retry." + } + + Write-Host "Installing Pester ($RequiredVersion) in CurrentUser scope..." + Install-Module -Name Pester -Scope CurrentUser -Force -RequiredVersion $RequiredVersion -AllowClobber | Out-Null + } + + Import-Module -Name Pester -RequiredVersion $RequiredVersion -Force +} + +$repoRoot = Resolve-IdleRepoRoot + +$resolvedTestPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $TestPath +if (-not (Test-Path -LiteralPath $resolvedTestPath)) { + throw "TestPath does not exist: $resolvedTestPath" +} + +$emitTestResults = $CI.IsPresent +$coverageEnabled = $CI.IsPresent -or $EnableCoverage.IsPresent + +$resolvedTestResultsPath = $null +if ($emitTestResults) { + $resolvedTestResultsPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $TestResultsPath + Initialize-Directory -Path (Split-Path -Path $resolvedTestResultsPath -Parent) +} + +$resolvedCoverageOutputPath = $null +$resolvedCoveragePaths = @() + +if ($coverageEnabled) { + $resolvedCoverageOutputPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $CoverageOutputPath + Initialize-Directory -Path (Split-Path -Path $resolvedCoverageOutputPath -Parent) + + foreach ($p in $CoveragePath) { + $resolvedCoveragePaths += (Get-IdleFullPath -RepoRootPath $repoRoot -Path $p) + } +} + +Initialize-Pester -RequiredVersion $PesterVersion + +$config = New-PesterConfiguration +$config.Run.Path = $resolvedTestPath +$config.Run.Exit = $true +$config.Output.Verbosity = 'Detailed' + +if ($emitTestResults -and $resolvedTestResultsPath) { + $config.TestResult.Enabled = $true + $config.TestResult.OutputFormat = 'NUnitXml' + $config.TestResult.OutputPath = $resolvedTestResultsPath +} + +if ($coverageEnabled -and $resolvedCoverageOutputPath) { + if (-not $config.PSObject.Properties.Name.Contains('CodeCoverage')) { + throw "Installed Pester does not expose CodeCoverage configuration. Install a newer Pester version and retry." + } + + $config.CodeCoverage.Enabled = $true + $config.CodeCoverage.Path = $resolvedCoveragePaths + + if ($config.CodeCoverage.PSObject.Properties.Name.Contains('OutputPath')) { + $config.CodeCoverage.OutputPath = $resolvedCoverageOutputPath + } + else { + throw "Installed Pester does not support CodeCoverage.OutputPath. Install a newer Pester version and retry." + } + + if ($config.CodeCoverage.PSObject.Properties.Name.Contains('OutputFormat')) { + $config.CodeCoverage.OutputFormat = $CoverageOutputFormat + } + elseif ($CI) { + throw "Installed Pester does not support CodeCoverage.OutputFormat. Install a newer Pester version and retry." + } +} + +Invoke-Pester -Configuration $config diff --git a/tools/Invoke-IdleScriptAnalyzer.ps1 b/tools/Invoke-IdleScriptAnalyzer.ps1 new file mode 100644 index 0000000..b066495 --- /dev/null +++ b/tools/Invoke-IdleScriptAnalyzer.ps1 @@ -0,0 +1,312 @@ +<# +.SYNOPSIS +Runs PSScriptAnalyzer for the IdLE repository with optional SARIF export. + +.DESCRIPTION +This script is the canonical entry point for running PSScriptAnalyzer in the IdLE repository. + +Design goals: +- Deterministic and CI-friendly defaults (repo-root settings, stable output paths) +- Optional SARIF output for GitHub Code Scanning integration +- No reliance on the current working directory +- Minimal dependencies with explicit version pinning + +By default, the script analyzes 'src' and 'tools' using the repository-root +'PSScriptAnalyzerSettings.psd1'. + +.PARAMETER Paths +One or more directories/files to analyze (relative to repo root by default). +Defaults to @('src', 'tools'). + +.PARAMETER SettingsPath +Path to the PSScriptAnalyzer settings file (relative to repo root by default). +Defaults to 'PSScriptAnalyzerSettings.psd1'. + +.PARAMETER CI +Enables CI mode: +- Writes a JSON summary file to -JsonOutputPath (default under artifacts/) +- Writes SARIF when -SarifOutputPath is provided (default under artifacts/) +- Fails the run when findings meet -FailOnSeverity (default: Error) + +.PARAMETER JsonOutputPath +Where to write a machine-readable JSON summary of findings +(relative to repo root by default). Defaults to 'artifacts/pssa-results.json'. + +.PARAMETER SarifOutputPath +When provided, writes SARIF output to this path (relative to repo root by default). +Defaults to 'artifacts/pssa-results.sarif'. + +.PARAMETER FailOnSeverity +Controls which findings should fail the run. Defaults to 'Error'. +Valid values: Error, Warning + +.PARAMETER PSScriptAnalyzerVersion +Pinned PSScriptAnalyzer version to use. Defaults to 1.24.0. + +.PARAMETER ConvertToSarifVersion +Pinned ConvertToSARIF module version to use (for SARIF export). Defaults to 1.0.0. + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 -CI + +.EXAMPLE +pwsh -NoProfile -File ./tools/Invoke-IdleScriptAnalyzer.ps1 -CI -SarifOutputPath artifacts/pssa.sarif + +.OUTPUTS +None. Writes optional output files and throws on failure. +#> + +[CmdletBinding()] +param( + [Parameter()] + [ValidateNotNullOrEmpty()] + [string[]] $Paths = @('src', 'tools'), + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $SettingsPath = 'PSScriptAnalyzerSettings.psd1', + + [Parameter()] + [switch] $CI, + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $JsonOutputPath = 'artifacts/pssa-results.json', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [string] $SarifOutputPath = 'artifacts/pssa-results.sarif', + + [Parameter()] + [ValidateSet('Error', 'Warning')] + [string] $FailOnSeverity = 'Error', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [version] $PSScriptAnalyzerVersion = '1.24.0', + + [Parameter()] + [ValidateNotNullOrEmpty()] + [version] $ConvertToSarifVersion = '1.0.0' +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +function Resolve-IdleRepoRoot { + <# + .SYNOPSIS + Resolves the repository root path. + + .DESCRIPTION + The repo root is assumed to be the parent directory of the 'tools' folder. + This avoids relying on the current working directory. + #> + [CmdletBinding()] + param() + + return (Resolve-Path -Path (Join-Path -Path $PSScriptRoot -ChildPath '..')).Path +} + +function Get-IdleFullPath { + <# + .SYNOPSIS + Returns a full path for a repository-relative or absolute path. + + .DESCRIPTION + Resolve-Path fails if the path does not exist (e.g. output files). + This helper returns a normalized full path regardless of existence. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $RepoRootPath, + + [Parameter(Mandatory)] + [string] $Path + ) + + if ([System.IO.Path]::IsPathRooted($Path)) { + return [System.IO.Path]::GetFullPath($Path) + } + + return [System.IO.Path]::GetFullPath((Join-Path -Path $RepoRootPath -ChildPath $Path)) +} + +function Initialize-Directory { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Path + ) + + if (-not (Test-Path -LiteralPath $Path)) { + New-Item -Path $Path -ItemType Directory -Force | Out-Null + } +} + +function Initialize-Module { + <# + .SYNOPSIS + Initializes a module by ensuring it is installed (pinned version) and imported. + + .DESCRIPTION + CI runners are ephemeral. When missing, we install the module in CurrentUser scope. + We explicitly pin versions for determinism. + + IMPORTANT: + - We keep this logic self-contained and consistent across local + CI runs. + - We avoid auto-upgrading to newer versions unless the pinned version is changed in code. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Name, + + [Parameter(Mandatory)] + [version] $RequiredVersion + ) + + $installed = Get-Module -ListAvailable -Name $Name | + Where-Object { $_.Version -eq $RequiredVersion } | + Select-Object -First 1 + + if (-not $installed) { + if (-not (Get-Command -Name Install-Module -ErrorAction SilentlyContinue)) { + throw "Module '$Name' ($RequiredVersion) is required, but Install-Module is not available. Install the module manually and retry." + } + + Write-Host "Installing module '$Name' ($RequiredVersion) in CurrentUser scope..." + Install-Module -Name $Name -Scope CurrentUser -Force -RequiredVersion $RequiredVersion -AllowClobber | Out-Null + } + + Import-Module -Name $Name -RequiredVersion $RequiredVersion -Force +} + +function Write-JsonFile { + <# + .SYNOPSIS + Writes a JSON file with deterministic encoding. + + .DESCRIPTION + PowerShell defaults can differ across versions. We enforce UTF8 without BOM and + ensure we always write a file (even when there are no findings). + #> + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string] $Path, + + [Parameter(Mandatory)] + [object] $Object + ) + + $json = $Object | ConvertTo-Json -Depth 10 + $encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $json, $encoding) +} + +$repoRoot = Resolve-IdleRepoRoot + +# Resolve and validate settings path. +$resolvedSettingsPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $SettingsPath +if (-not (Test-Path -LiteralPath $resolvedSettingsPath)) { + throw "PSScriptAnalyzer settings file not found: $resolvedSettingsPath" +} + +# Resolve analysis paths relative to repo root. +$resolvedPaths = @() +foreach ($p in $Paths) { + $full = Get-IdleFullPath -RepoRootPath $repoRoot -Path $p + if (-not (Test-Path -LiteralPath $full)) { + throw "Analysis path not found: $full" + } + + $resolvedPaths += $full +} + +# Resolve output paths. We always write JSON in CI mode for artifact upload. +$resolvedJsonOutputPath = $null +if ($CI) { + $resolvedJsonOutputPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $JsonOutputPath + Initialize-Directory -Path (Split-Path -Path $resolvedJsonOutputPath -Parent) +} + +# SARIF is optional: only generate when CI is on (or user explicitly wants it later) +# AND ConvertToSARIF is available. +$resolvedSarifOutputPath = $null +$emitSarif = $false +if ($CI -and $SarifOutputPath) { + $resolvedSarifOutputPath = Get-IdleFullPath -RepoRootPath $repoRoot -Path $SarifOutputPath + Initialize-Directory -Path (Split-Path -Path $resolvedSarifOutputPath -Parent) + $emitSarif = $true +} + +# Ensure analyzer module is present (pinned). +Initialize-Module -Name 'PSScriptAnalyzer' -RequiredVersion $PSScriptAnalyzerVersion + +# Run analysis using the repo settings file. +# We rely on the settings file for rule selection and severities. +Write-Host "Running PSScriptAnalyzer ($PSScriptAnalyzerVersion) using settings: $resolvedSettingsPath" +Write-Host "Analyzing paths:" +$resolvedPaths | ForEach-Object { Write-Host " - $_" } + +$findings = @() +foreach ($path in $resolvedPaths) { + $findings += Invoke-ScriptAnalyzer -Path $path -Recurse -Settings $resolvedSettingsPath +} + +# Create a stable, small JSON payload (DiagnosticRecord contains complex members). +$summary = @( + foreach ($f in ($findings | Sort-Object ScriptName, Line, Column, RuleName)) { + [pscustomobject]@{ + RuleName = $f.RuleName + Severity = $f.Severity + Message = $f.Message + ScriptName = $f.ScriptName + Line = $f.Line + Column = $f.Column + } + } +) + +if ($CI -and $resolvedJsonOutputPath) { + Write-Host "Writing PSScriptAnalyzer JSON results: $resolvedJsonOutputPath" + Write-JsonFile -Path $resolvedJsonOutputPath -Object $summary +} + +if ($emitSarif -and $resolvedSarifOutputPath) { + # ConvertToSARIF provides the ConvertTo-SARIF cmdlet which accepts -FilePath. + # We install it only when SARIF output is requested. + Initialize-Module -Name 'ConvertToSARIF' -RequiredVersion $ConvertToSarifVersion + + $convertCommand = Get-Command -Name 'ConvertTo-SARIF' -ErrorAction SilentlyContinue + if (-not $convertCommand) { + throw "ConvertToSARIF module is installed, but 'ConvertTo-SARIF' cmdlet was not found." + } + + Write-Host "Writing SARIF results: $resolvedSarifOutputPath" + $findings | & $convertCommand -FilePath $resolvedSarifOutputPath +} + +# Determine whether we should fail this run. +$failSeverities = @($FailOnSeverity) +if ($FailOnSeverity -eq 'Warning') { + # Warning implies: fail on both Warning and Error. + $failSeverities = @('Warning', 'Error') +} + +$blockingFindings = $findings | Where-Object { $failSeverities -contains $_.Severity } + +if ($blockingFindings) { + $errorCount = ($findings | Where-Object { $_.Severity -eq 'Error' }).Count + $warningCount = ($findings | Where-Object { $_.Severity -eq 'Warning' }).Count + + $message = "PSScriptAnalyzer found blocking issues (FailOnSeverity: $FailOnSeverity). Errors: $errorCount, Warnings: $warningCount." + throw $message +} + +Write-Host "PSScriptAnalyzer completed with no blocking findings (FailOnSeverity: $FailOnSeverity)." diff --git a/tools/run-tests.ps1 b/tools/run-tests.ps1 deleted file mode 100644 index f05b807..0000000 --- a/tools/run-tests.ps1 +++ /dev/null @@ -1,54 +0,0 @@ -[CmdletBinding()] -param( - [Parameter()] - [string] $TestPath = 'tests', - - [Parameter()] - [switch] $CI, - - [Parameter()] - [string] $TestResultsPath = 'artifacts/test-results.xml' -) - -Set-StrictMode -Off -$ErrorActionPreference = 'Stop' - -function Test-Pester { - [CmdletBinding()] - param( - [Parameter()] - [version] $MinimumVersion = '5.0.0' - ) - - $pester = Get-Module -ListAvailable -Name Pester | - Sort-Object Version -Descending | - Select-Object -First 1 - - if (-not $pester -or $pester.Version -lt $MinimumVersion) { - Write-Host "Installing Pester >= $MinimumVersion (CurrentUser scope)..." - Install-Module -Name Pester -Scope CurrentUser -Force -MinimumVersion $MinimumVersion - } - - Import-Module -Name Pester -MinimumVersion $MinimumVersion -Force -} - -# Ensure output folder exists (for CI artifacts) -$resultsDir = Split-Path -Path $TestResultsPath -Parent -if ($resultsDir -and -not (Test-Path -Path $resultsDir)) { - New-Item -Path $resultsDir -ItemType Directory -Force | Out-Null -} - -Test-Pester -MinimumVersion '5.0.0' - -$config = New-PesterConfiguration -$config.Run.Path = $TestPath -$config.Run.Exit = $true -$config.Output.Verbosity = 'Detailed' - -if ($CI) { - $config.TestResult.Enabled = $true - $config.TestResult.OutputFormat = 'NUnitXml' - $config.TestResult.OutputPath = $TestResultsPath -} - -Invoke-Pester -Configuration $config