From b3574c1d3421a4be1b099d8a1b6afc57251b255a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 19 Aug 2026 17:24:48 -0400 Subject: [PATCH 1/4] fix(lint): make the analyzer settings file the single source PowerShellBuild's default SettingsPath resolves inside its own module directory, so PSScriptAnalyzerSettings.psd1 was never read. CI also passed -Settings PSGallery over the source tree while a local build analysed the built module with a third ruleset. Lint now runs through the build in both places. --- .github/workflows/CI.yaml | 67 +++++++++++++-------------------------- build.psake.ps1 | 10 ++++++ 2 files changed, 32 insertions(+), 45 deletions(-) diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml index e308cc0..48b5da1 100644 --- a/.github/workflows/CI.yaml +++ b/.github/workflows/CI.yaml @@ -21,11 +21,17 @@ jobs: steps: - uses: actions/checkout@v7 - # Skip lint on the un-initialized template — the literal `./{{ModuleName}}` - # path argument can't be parsed by PowerShell (the double braces split into - # mismatched script-block delimiters), so Invoke-ScriptAnalyzer fails with - # a positional-argument error before it ever touches the folder. Same - # marker as the unit-tests job below. + # Skip lint on the un-initialized template. Every path in the manifest and + # build still holds a literal `{{ModuleName}}`, so the module cannot be + # built and there is nothing for the analyzer to run against. Same marker + # as the unit-tests job below. + # + # This previously guarded a different failure: the analyzer was invoked + # directly with `./{{ModuleName}}` as its path argument, and the double + # braces split into mismatched script-block delimiters before PowerShell + # got as far as the folder. Lint now runs through ./build.ps1, so that + # specific breakage is gone -- the guard remains because an un-initialized + # template still cannot build. - name: Detect template state id: template_guard shell: bash @@ -36,52 +42,23 @@ jobs: echo "is_template=false" >> "$GITHUB_OUTPUT" fi - # No module cache here on purpose -- see the note in the unit-tests job. - # The cache this replaced held 209 bytes: PSScriptAnalyzer ships on the - # runner image, so it was never installed into the cached path and the - # cache only ever restored an empty directory. Install only when the - # image does not already provide it, rather than unconditionally -- the - # module is ~339 MB on disk and re-downloading it every run is far more - # expensive than the cache ever saved. - - name: Install PSScriptAnalyzer + # Lint through the build rather than calling Invoke-ScriptAnalyzer directly. + # + # The direct call passed -Settings PSGallery and analysed the source tree, + # while ./build.ps1 -Task Analyze uses this repository's + # PSScriptAnalyzerSettings.psd1 and analyses the built module. Two rulesets + # over two different paths, so a local run and this job could disagree + # without either being wrong. Running the same command both places makes + # them agree by construction instead of by intention. + - name: Install dependencies if: steps.template_guard.outputs.is_template == 'false' shell: pwsh - run: | - # Pin to the version build.depend.psd1 declares, so lint results here match - # a local ./build.ps1 -Task Analyze. Accepting whatever the runner image - # happens to ship means the two can disagree silently. - $required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version - $installed = Get-Module -Name PSScriptAnalyzer -ListAvailable | - Where-Object { $_.Version -eq $required } - if ($installed) { - Write-Host "PSScriptAnalyzer $required already available; skipping install." - return - } - - Set-PSRepository -Name PSGallery -InstallationPolicy Trusted - Install-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -Scope CurrentUser + run: ./build.ps1 -Bootstrap -Task Init - name: Run PSScriptAnalyzer if: steps.template_guard.outputs.is_template == 'false' shell: pwsh - run: | - $required = (Import-PowerShellDataFile -Path build.depend.psd1).PSScriptAnalyzer.Version - Import-Module -Name PSScriptAnalyzer -RequiredVersion $required -Force -ErrorAction Stop - $results = Invoke-ScriptAnalyzer -Path ./{{ModuleName}} -Recurse -Settings PSGallery -ReportSummary - $errors = $results | Where-Object { $_.Severity -eq 'Error' } - - if ($results) { - Write-Host "::group::PSScriptAnalyzer Results" - $results | Format-Table -AutoSize - Write-Host "::endgroup::" - } - - if ($errors) { - Write-Host "::error::PSScriptAnalyzer found $($errors.Count) error(s)" - exit 1 - } - - Write-Host "PSScriptAnalyzer passed with no errors" + run: ./build.ps1 -Task Analyze unit-tests: name: Unit Tests (${{ matrix.os }}) diff --git a/build.psake.ps1 b/build.psake.ps1 index fcf46c9..cb273ec 100644 --- a/build.psake.ps1 +++ b/build.psake.ps1 @@ -10,6 +10,16 @@ properties { # Set this to $true to create a module with a monolithic PSM1 $PSBPreference.Build.CompileModule = $false $PSBPreference.Help.DefaultLocale = 'en-US' + # Point PSScriptAnalyzer at this repository's settings file. + # + # PowerShellBuild's default SettingsPath is + # Join-Path $PSScriptRoot 'ScriptAnalyzerSettings.psd1' where $PSScriptRoot is + # PowerShellBuild's own module directory -- so without this line the analyzer + # runs against the settings bundled with PowerShellBuild and the + # PSScriptAnalyzerSettings.psd1 in this repository is never read. Note the + # filenames differ too, so the default cannot pick it up by accident. + $PSBPreference.Test.ScriptAnalysis.SettingsPath = + Join-Path -Path $PSScriptRoot -ChildPath 'PSScriptAnalyzerSettings.psd1' # Use absolute paths for test output (relative paths resolve from tests directory) $PSBPreference.Test.OutputFile = [IO.Path]::Combine($PSScriptRoot, 'out', 'testResults.xml') $PSBPreference.Test.OutputFormat = 'NUnitXml' From 1bdee0d6edd5d23d94a1c2ab7ac645007d9ac2d1 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 19 Aug 2026 20:02:10 -0400 Subject: [PATCH 2/4] chore(lint): converge the analyzer settings on Microsoft's guidance Adds Severity = @('Error','Warning'), which Microsoft's documented settings example uses and which nothing here set, so Information-level findings were in scope by omission rather than decision. --- PSScriptAnalyzerSettings.psd1 | 30 +++++------------------------- 1 file changed, 5 insertions(+), 25 deletions(-) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index a521c0e..d545d80 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,28 +1,8 @@ -# https://learn.microsoft.com/en-us/powershell/utility-modules/psscriptanalyzer/using-scriptanalyzer @{ - IncludeDefaultRules = $true + IncludeRules = @('*') - IncludeRules = @( - # Default rules - 'PS*' - ) - - # If IncludeRules and ExcludeRules are empty, all rules will be applied - ExcludeRules = @() - - Rules = @{ - # PSUseCompatibleSyntax = @{ - # # This turns the rule on (setting it to false will turn it off) - # Enable = $true - - # # List the targeted versions of PowerShell here - # TargetVersions = @( - # '5.1', - # '7.2' - # ) - # } - # PSUseCompatibleCmdlets = @{ - # compatibility = @('core-7.2.0-windows') - # } - } + # Scope analysis to actionable severities. Microsoft's documented settings + # example does the same; without it Information-level findings are in scope + # with nothing having decided that they should be. + Severity = @('Error', 'Warning') } From 17db54972caffeee92d685c5c2975c3fbdeb581a Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 19 Aug 2026 21:22:00 -0400 Subject: [PATCH 3/4] chore(lint): drop the redundant IncludeRules entry IncludeRules = @('*') is a no-op: measured identical findings with and without it, including on the settings file that enables the formatting rules, where Enable = $true in the Rules block is what makes them run. Microsoft's documented settings example omits it entirely. --- PSScriptAnalyzerSettings.psd1 | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index d545d80..3baa069 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,8 +1,6 @@ @{ - IncludeRules = @('*') - # Scope analysis to actionable severities. Microsoft's documented settings # example does the same; without it Information-level findings are in scope # with nothing having decided that they should be. - Severity = @('Error', 'Warning') + Severity = @('Error', 'Warning') } From 46982b457b5f4edc442d1c62400b48e2f98fb090 Mon Sep 17 00:00:00 2001 From: Trent Blackburn Date: Wed, 19 Aug 2026 22:15:59 -0400 Subject: [PATCH 4/4] fix(lint): keep ParseError in the severity filter Naming Severity without ParseError hides syntax-broken files entirely: a file with a missing brace reports zero findings under @('Error','Warning'). Microsoft documents this as the way to suppress parse errors, which is the opposite of what a lint gate wants. --- PSScriptAnalyzerSettings.psd1 | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 index 3baa069..b35a9ae 100644 --- a/PSScriptAnalyzerSettings.psd1 +++ b/PSScriptAnalyzerSettings.psd1 @@ -1,6 +1,11 @@ @{ - # Scope analysis to actionable severities. Microsoft's documented settings - # example does the same; without it Information-level findings are in scope - # with nothing having decided that they should be. - Severity = @('Error', 'Warning') + # Scope analysis to actionable severities, dropping Information-level noise. + # + # ParseError must be listed. It is how PSScriptAnalyzer reports a file it + # could not parse at all, and naming Severity without it silently hides + # syntax-broken files: a file with a missing brace reports zero findings + # under @('Error', 'Warning'). Microsoft's documentation states this + # directly -- "To suppress ParseErrors, don't include it as a value in the + # Severity parameter" -- which is exactly what must not happen to a lint gate. + Severity = @('ParseError', 'Error', 'Warning') }