From 9c20ff89c854df15393485da43db1aadfc04a90e Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 12 Jun 2026 10:04:37 +0200 Subject: [PATCH 1/4] Update release notes and changelog for version 1.2.0; add new features and cleanup functions in SPSCleanDependencies --- .github/workflows/pester.yml | 83 ++++++++ .gitignore | 5 + CHANGELOG.md | 20 ++ RELEASE-NOTES.md | 24 +-- .../Modules/SPSCleanDependencies.util.psm1 | 198 +++++++++++++++++- scripts/SPSCleanDependencies.ps1 | 22 +- .../SPSCleanDependencies.util.Tests.ps1 | 172 +++++++++++++++ tests/SPSCleanDependencies.Tests.ps1 | 178 ++++++++++++++++ 8 files changed, 679 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/pester.yml create mode 100644 .gitignore create mode 100644 tests/Modules/SPSCleanDependencies.util.Tests.ps1 create mode 100644 tests/SPSCleanDependencies.Tests.ps1 diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml new file mode 100644 index 0000000..f57b2fe --- /dev/null +++ b/.github/workflows/pester.yml @@ -0,0 +1,83 @@ +# This is the SPSCleanDependencies CI Pester workflow to run tests on pull requests + +name: SPSCleanDependencies CI Pester Tests + +on: + pull_request: + branches: + - main + paths: + - 'scripts/**' + - 'tests/**' + +jobs: + test: + name: Run Pester Tests + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Pester + shell: pwsh + run: | + Install-Module -Name Pester -MinimumVersion 5.3.0 -Force -SkipPublisherCheck -Scope CurrentUser + Import-Module Pester + + - name: Run Pester Tests + shell: pwsh + run: | + $config = New-PesterConfiguration + $config.Run.Path = './tests' + $config.Run.Exit = $true + $config.TestResult.Enabled = $true + $config.TestResult.OutputPath = './test-results.xml' + $config.Output.Verbosity = 'Detailed' + + Invoke-Pester -Configuration $config + + - name: Upload test results + uses: actions/upload-artifact@v4 + if: always() + with: + name: test-results + path: test-results.xml + + - name: Publish Test Results + uses: EnricoMi/publish-unit-test-result-action/composite@v2 + if: always() + with: + files: | + test-results.xml + check_name: Pester Test Results + + code-quality: + name: Code Quality Check + runs-on: windows-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install PSScriptAnalyzer + shell: pwsh + run: | + Install-Module -Name PSScriptAnalyzer -Force -SkipPublisherCheck -Scope CurrentUser + + - name: Run PSScriptAnalyzer + shell: pwsh + run: | + $scriptFiles = Get-ChildItem -Path ./scripts -Include '*.ps1', '*.psm1' -Recurse + + $results = $scriptFiles | ForEach-Object { + Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning + } + + if ($results) { + $results | Format-Table -AutoSize + Write-Error "PSScriptAnalyzer found issues" + exit 1 + } else { + Write-Host "No issues found by PSScriptAnalyzer" + } diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8eb03c8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +# Ignore vscode files +.vscode/ + +# Ignore DS_Store files +**/.DS_Store \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index f09f587..525e7a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,26 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.2.0] - 2026-06-11 + +### Added + +- SPSCleanDependencies.util.psm1: + - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations (SiteID/WebID/ListID/DirName/LeafName). + - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). + - Extend `SPMissingWebPartInfo` class with `ClassName`, `StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`. + +- SPSCleanDependencies.ps1: + - Implement the `MissingWebPart` cleanup branch (previously a no-op). + - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. + +- Pester test suite under `tests/`: + - `tests/SPSCleanDependencies.Tests.ps1` - script-level tests (metadata, parameters, module imports, Clean branch wiring). + - `tests/Modules/SPSCleanDependencies.util.Tests.ps1` - module-level tests (public/SQL function contracts, class shapes, safety net for empty `StorageKey`). + +- CI: + - `.github/workflows/pester.yml` - runs Pester 5.3+ on `windows-latest` for pull requests to `main`, plus a `PSScriptAnalyzer` code-quality job. + ## [1.1.0] - 2025-10-21 ### Changed diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 9fdc835..12921ca 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,22 +1,20 @@ # SPSCleanDependencies - Release Notes -## [1.1.0] - 2025-10-21 +## [1.2.0] - 2026-06-11 -### Changed +### Added -- SPSCleanDependencies.ps1: - - Resolve Invoke-Sqlcmd does not work because sqlserver is not present [issue #2](https://github.com/luigilink/SPSCleanDependencies/issues/2) - - Resolve Performing the operation "Set-SPSite" on target "*sitemaster-*" [issue #3](https://github.com/luigilink/SPSCleanDependencies/issues/3) +- SPSCleanDependencies.util.psm1: + - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations. + - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). + - Extend `SPMissingWebPartInfo` class with location fields (`StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`). -- Wiki Documentation in repository - Update : - - wiki/Home.md - - wiki/Getting-Started.md - - wiki/Usage.md +- SPSCleanDependencies.ps1: + - Implement the `MissingWebPart` cleanup branch (previously a no-op). + - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. -- Issue Templates files: - - 1_bug_report.yml Update version +- Pester test suite under `tests/` covering the script and helper module. -- README.md - - Add Requirements for PowerShell 5 and SqlServer PowerShell Module +- `.github/workflows/pester.yml` CI workflow running Pester 5.3+ and `PSScriptAnalyzer` on `windows-latest`. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 index 3609367..b54c385 100644 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ b/scripts/Modules/SPSCleanDependencies.util.psm1 @@ -69,6 +69,13 @@ class SPMissingWebPartInfo { [System.String]$WebPartID [System.String]$Message [System.String]$Remedy + [System.String]$ClassName + [System.String]$StorageKey + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName } class SPMissingSetupFileInfo { [System.String]$Database @@ -217,6 +224,64 @@ FROM EventReceivers (NOLOCK) where Assembly = '$($AssemblyInfo)' return $tbSQLmissingAssemblies } +function Get-SQLMissingWebPartInfo { + param + ( + [Parameter()] + [System.String] + $DatabaseName, + + [Parameter()] + [System.String] + $DatabaseServer, + + [Parameter()] + [System.String] + $ClassID + ) + + class SQLMissingWebPartInfo { + [System.String]$StorageKey + [System.String]$ClassName + [System.String]$SiteID + [System.String]$WebID + [System.String]$ListID + [System.String]$DirName + [System.String]$LeafName + } + $tbSQLmissingWebParts = New-Object -TypeName System.Collections.ArrayList + try { + $sqlQuery = + @" +USE $($DatabaseName) +SELECT wp.tp_ID, d.SiteId, d.WebId, d.ListId, d.DirName, d.LeafName, wp.tp_Class +FROM AllDocs d WITH (NOLOCK) +INNER JOIN AllWebParts wp WITH (NOLOCK) ON wp.tp_PageUrlID = d.Id +INNER JOIN AllWebs w WITH (NOLOCK) ON d.WebId = w.id +WHERE wp.tp_WebPartTypeId = '$($ClassID)' +"@ + + $invokeSQLQueries = Invoke-Sqlcmd -Query $sqlQuery ` + -ServerInstance "$($DatabaseServer)" + + foreach ($invokeSQLQuery in $invokeSQLQueries) { + [void]$tbSQLmissingWebParts.Add([SQLMissingWebPartInfo]@{ + StorageKey = $invokeSQLQuery.tp_ID; + ClassName = $invokeSQLQuery.tp_Class; + SiteID = $invokeSQLQuery.SiteId; + WebID = $invokeSQLQuery.WebId; + ListID = $invokeSQLQuery.ListId; + DirName = $invokeSQLQuery.DirName; + LeafName = $invokeSQLQuery.LeafName; + }) + } + } + catch { + return $_ + } + return $tbSQLmissingWebParts +} + function Get-SQLMissingConfiguration { param ( @@ -298,13 +363,37 @@ function Get-SPSMissingServerDependencies { if ($null -ne $missingWebParts) { foreach ($missingWebPart in $missingWebParts) { $webPartID = ([regex]::Matches($missingWebPart.Message, '[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12}')).value - [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ - Database = "$($spContentDB.Name)"; - Category = $missingWebPart.Category; - WebPartID = $webPartID - Message = $missingWebPart.Message; - Remedy = $missingWebPart.Remedy; - }) + $sqlMissingWebParts = Get-SQLMissingWebPartInfo -DatabaseName "$($spContentDB.Name)" ` + -DatabaseServer "$($spContentDB.Server)" ` + -ClassID "$($webPartID)" + + if ($null -ne $sqlMissingWebParts -and $sqlMissingWebParts.Count -gt 0) { + foreach ($sqlMissingWebPart in $sqlMissingWebParts) { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + ClassName = $sqlMissingWebPart.ClassName; + StorageKey = $sqlMissingWebPart.StorageKey; + SiteID = $sqlMissingWebPart.SiteID; + WebID = $sqlMissingWebPart.WebID; + ListID = $sqlMissingWebPart.ListID; + DirName = $sqlMissingWebPart.DirName; + LeafName = $sqlMissingWebPart.LeafName; + }) + } + } + else { + [void]$tbSPmissingWebParts.Add([SPMissingWebPartInfo]@{ + Database = "$($spContentDB.Name)"; + Category = $missingWebPart.Category; + WebPartID = $webPartID; + Message = $missingWebPart.Message; + Remedy = $missingWebPart.Remedy; + }) + } } } if ($null -ne $missingSetupFiles) { @@ -560,6 +649,101 @@ function Remove-SPSMissingSetupFile { } } +function Remove-SPSMissingWebPart { + param + ( + [Parameter()] + [System.String] + $Database, + + [Parameter()] + [System.String] + $WebPartID, + + [Parameter()] + [System.String] + $StorageKey, + + [Parameter()] + [System.String] + $SiteID, + + [Parameter()] + [System.String] + $WebID, + + [Parameter()] + [System.String] + $DirName, + + [Parameter()] + [System.String] + $LeafName + ) + + try { + Write-Output '-----------------------------------------------' + Write-Output 'Removing Missing WebPart Dependencies of:' + Write-Output " * Database: $Database" + Write-Output " * WebPartID (class): $WebPartID" + Write-Output " * StorageKey: $StorageKey" + Write-Output " * SiteID: $SiteID" + Write-Output " * WebID: $WebID" + Write-Output " * Page: $DirName/$LeafName" + Write-Output '-----------------------------------------------' + + if ([string]::IsNullOrEmpty($StorageKey)) { + Write-Output 'StorageKey is empty - no specific WebPart instance to remove. Re-run the audit to collect per-page locations.' + return + } + + $site = Get-SPSite -Limit All -Identity $SiteID -ErrorAction SilentlyContinue + if ($null -ne $site) { + $isSiteReadOnly = $site.ReadOnly + $web = Get-SPWeb -Identity $WebID -Site $site -Limit ALL -ErrorAction SilentlyContinue + if ($null -ne $web) { + $webAppUrl = ($site.WebApplication.Url).TrimEnd('/') + $pageUrl = "{0}/{1}/{2}" -f $webAppUrl, $DirName, $LeafName + Write-Output "Page URL: $pageUrl" + + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag temporarily removed from site' + $site.ReadOnly = $false + } + + try { + $spWebPartManager = $web.GetLimitedWebPartManager($pageUrl, [System.Web.UI.WebControls.WebParts.PersonalizationScope]::Shared) + $webPartToDelete = $spWebPartManager.WebParts | Where-Object -FilterScript { $_.StorageKey -eq $StorageKey } + if ($null -ne $webPartToDelete) { + Write-Output "Removing WebPart with StorageKey $StorageKey from page" + $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) + } + else { + Write-Output "WebPart with StorageKey $StorageKey not found on page" + } + } + finally { + if ($isSiteReadOnly) { + Write-Output 'ReadOnly flag re-applied to site' + $site.ReadOnly = $true + } + $web.Dispose() + } + } + else { + Write-Output "WebID $WebID does not exist.`nPlease check this WebID" + } + $site.Dispose() + } + else { + Write-Output "SiteID $SiteID does not exist.`nPlease check this SiteID" + } + } + catch { + return $_ + } +} + function Remove-SPSMissingAssembly { param ( diff --git a/scripts/SPSCleanDependencies.ps1 b/scripts/SPSCleanDependencies.ps1 index 4bc4d48..f76a690 100644 --- a/scripts/SPSCleanDependencies.ps1 +++ b/scripts/SPSCleanDependencies.ps1 @@ -22,8 +22,8 @@ .NOTES FileName: SPSCleanDependencies.ps1 Author: luigilink (Jean-Cyril DROUHIN) - Date: October 21, 2025 - Version: 1.1.0 + Date: June 11, 2026 + Version: 1.2.0 .LINK https://spjc.fr/ @@ -80,7 +80,7 @@ Exception: $_ } # Define variable -$SPSCleanDependenciesVersion = '1.1.0' +$SPSCleanDependenciesVersion = '1.2.0' $currentUser = ([Security.Principal.WindowsIdentity]::GetCurrent()).Name $scriptRootPath = Split-Path -parent $MyInvocation.MyCommand.Definition $pathLogsFolder = Join-Path -Path $scriptRootPath -ChildPath 'Logs' -ErrorAction SilentlyContinue @@ -127,6 +127,15 @@ if ($Clean) { } if (($jsonEnvCfg.MissingWebPart).Count -ne 0) { Write-Output 'Removing Missing WebPart References' + foreach ($missingWebPart in $jsonEnvCfg.MissingWebPart) { + Remove-SPSMissingWebPart -Database $missingWebPart.Database ` + -WebPartID $missingWebPart.WebPartID ` + -StorageKey $missingWebPart.StorageKey ` + -SiteID $missingWebPart.SiteID ` + -WebID $missingWebPart.WebID ` + -DirName $missingWebPart.DirName ` + -LeafName $missingWebPart.LeafName + } } if (($jsonEnvCfg.MissingSetupFile).Count -ne 0) { Write-Output 'Removing Missing SetupFile References' @@ -148,6 +157,13 @@ if ($Clean) { -HostID $missingAssembly.HostID } } + if (($jsonEnvCfg.SiteOrphan).Count -ne 0) { + Write-Output 'Removing Orphaned Site References' + foreach ($orphanedSite in $jsonEnvCfg.SiteOrphan) { + Remove-SPSOrphanedSite -Database $orphanedSite.Database ` + -SiteID $orphanedSite.SiteID + } + } if (($jsonEnvCfg.Configuration).Count -ne 0) { Write-Output 'Checking Classic Authentication Account of each SPSite object' $defautSPSiteOwner = (Get-SPFarm).DefaultServiceAccount.Name diff --git a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 new file mode 100644 index 0000000..c9b41ab --- /dev/null +++ b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 @@ -0,0 +1,172 @@ +# Pester tests for SPSCleanDependencies.util.psm1 +# Resolve repo root - works on both local and CI/CD + +BeforeAll { + $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent + $script:modulePath = Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/SPSCleanDependencies.util.psm1' + + # Stub SharePoint cmdlets so the module can be imported on non-Windows / no-SharePoint hosts. + # Real behaviour is exercised on a SharePoint farm; these tests only validate shape & contracts. + $spsStubs = @( + 'Get-SPContentDatabase', 'Get-SPSite', 'Get-SPWeb', 'Get-SPFarm', 'Set-SPSite', + 'Test-SPContentDatabase', 'Install-SPFeature', 'Uninstall-SPFeature', + 'Disable-SPFeature', 'Invoke-Sqlcmd', 'Add-PSSnapin', 'Get-PSSnapin' + ) + foreach ($name in $spsStubs) { + if (-not (Get-Command -Name $name -ErrorAction SilentlyContinue)) { + $sb = [ScriptBlock]::Create("function global:$name { param() }") + & $sb + } + } + + Import-Module -Name $script:modulePath -Force -DisableNameChecking -ErrorAction SilentlyContinue +} + +AfterAll { + Remove-Module -Name 'SPSCleanDependencies.util' -Force -ErrorAction SilentlyContinue +} + +Describe 'SPSCleanDependencies.util.psm1 Module' { + + It 'module file exists' { + $script:modulePath | Should -Exist + } + + It 'has valid PowerShell syntax' { + $parseErrors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput( + (Get-Content -Path $script:modulePath -Raw), [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } + + It 'module loads successfully' { + Get-Module -Name 'SPSCleanDependencies.util' | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 Public Functions' { + + $publicFunctions = @( + 'Get-SPSMissingServerDependencies', + 'Remove-SPSMissingFeature', + 'Remove-SPSMissingSetupFile', + 'Remove-SPSMissingAssembly', + 'Remove-SPSMissingConfiguration', + 'Remove-SPSMissingWebPart', + 'Remove-SPSOrphanedSite' + ) + + It 'exports <_>' -ForEach $publicFunctions { + Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 SQL Helper Functions' { + + $sqlHelpers = @( + 'Get-SQLMissingSetupFileInfo', + 'Get-SQLMissingAssemblyInfo', + 'Get-SQLMissingWebPartInfo', + 'Get-SQLMissingConfiguration' + ) + + It 'defines helper <_>' -ForEach $sqlHelpers { + Get-Command -Name $_ -ErrorAction SilentlyContinue | Should -Not -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.util.psm1 Function Parameter Contracts' { + + It 'Get-SPSMissingServerDependencies has a Path parameter' { + (Get-Command Get-SPSMissingServerDependencies).Parameters.Keys | Should -Contain 'Path' + } + + It 'Remove-SPSMissingFeature exposes Database, FeatureID, SiteID' { + $params = (Get-Command Remove-SPSMissingFeature).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'FeatureID' + $params | Should -Contain 'SiteID' + } + + It 'Remove-SPSMissingSetupFile exposes Database, FileID, SiteID, WebID' { + $params = (Get-Command Remove-SPSMissingSetupFile).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'FileID' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + } + + It 'Remove-SPSMissingAssembly exposes the full HostType contract' { + $params = (Get-Command Remove-SPSMissingAssembly).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'AssemblyID' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + $params | Should -Contain 'HostType' + $params | Should -Contain 'HostID' + } + + It 'Remove-SPSMissingConfiguration exposes Database, SiteID, Login' { + $params = (Get-Command Remove-SPSMissingConfiguration).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'Login' + } + + It 'Remove-SPSMissingWebPart exposes the per-page location contract' { + $params = (Get-Command Remove-SPSMissingWebPart).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'WebPartID' + $params | Should -Contain 'StorageKey' + $params | Should -Contain 'SiteID' + $params | Should -Contain 'WebID' + $params | Should -Contain 'DirName' + $params | Should -Contain 'LeafName' + } + + It 'Remove-SPSOrphanedSite exposes Database and SiteID' { + $params = (Get-Command Remove-SPSOrphanedSite).Parameters.Keys + $params | Should -Contain 'Database' + $params | Should -Contain 'SiteID' + } +} + +Describe 'Remove-SPSMissingWebPart Safety Net' { + + It 'returns early (no throw) when StorageKey is empty' { + { Remove-SPSMissingWebPart ` + -Database 'WSS_Content_Test' ` + -WebPartID '00000000-0000-0000-0000-000000000000' ` + -StorageKey '' ` + -SiteID '11111111-1111-1111-1111-111111111111' ` + -WebID '22222222-2222-2222-2222-222222222222' ` + -DirName 'SitePages' ` + -LeafName 'Home.aspx' } | Should -Not -Throw + } +} + +Describe 'SPSCleanDependencies.util.psm1 Class Definitions' { + + BeforeAll { + $script:moduleContent = Get-Content -Path $script:modulePath -Raw + } + + $expectedClasses = @( + 'SPMissingFeaturesInfo', + 'SPMissingWebPartInfo', + 'SPMissingSetupFileInfo', + 'SPMissingAssemblyInfo', + 'SPMissingConfigurationInfo', + 'SPMissingSiteDefinition', + 'SPMissingOrphanedSites' + ) + + It 'defines class <_>' -ForEach $expectedClasses { + $script:moduleContent | Should -Match "class\s+$_\b" + } + + It 'SPMissingWebPartInfo class carries per-page location fields' { + $script:moduleContent | Should -Match 'class\s+SPMissingWebPartInfo[\s\S]*\$StorageKey[\s\S]*\$DirName[\s\S]*\$LeafName' + } +} diff --git a/tests/SPSCleanDependencies.Tests.ps1 b/tests/SPSCleanDependencies.Tests.ps1 new file mode 100644 index 0000000..f41bd22 --- /dev/null +++ b/tests/SPSCleanDependencies.Tests.ps1 @@ -0,0 +1,178 @@ +# Pester tests for SPSCleanDependencies.ps1 +# Resolve repo root - works on CI/CD (GitHub Actions) and local runs + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $script:scriptPath = Join-Path -Path $repoRoot -ChildPath 'scripts/SPSCleanDependencies.ps1' + $script:scriptContent = Get-Content -Path $script:scriptPath -Raw -ErrorAction SilentlyContinue +} + +Describe 'SPSCleanDependencies.ps1 File Existence' { + + It 'SPSCleanDependencies.ps1 exists' { + $script:scriptPath | Should -Exist + } + + It 'is a PowerShell script file' { + (Get-Item $script:scriptPath).Extension | Should -Be '.ps1' + } + + It 'has valid PowerShell syntax' { + $parseErrors = $null + $tokens = $null + $null = [System.Management.Automation.Language.Parser]::ParseInput( + $script:scriptContent, [ref]$tokens, [ref]$parseErrors) + $parseErrors | Should -BeNullOrEmpty + } +} + +Describe 'SPSCleanDependencies.ps1 Metadata' { + + It 'Should contain a SYNOPSIS' { + $script:scriptContent | Should -Match '\.SYNOPSIS' + } + + It 'Should contain a DESCRIPTION' { + $script:scriptContent | Should -Match '\.DESCRIPTION' + } + + It 'Should contain an EXAMPLE' { + $script:scriptContent | Should -Match '\.EXAMPLE' + } + + It 'Should declare an Author in NOTES' { + $script:scriptContent | Should -Match 'Author:\s*luigilink' + } + + It 'Should declare a Version in NOTES' { + $script:scriptContent | Should -Match 'Version:\s*\d+\.\d+\.\d+' + } + + It 'Should require PowerShell 5.1 or higher' { + $script:scriptContent | Should -Match '#requires\s+-Version\s+5\.1' + } +} + +Describe 'SPSCleanDependencies.ps1 Parameters' { + + BeforeAll { + $ast = [System.Management.Automation.Language.Parser]::ParseInput( + $script:scriptContent, [ref]$null, [ref]$null) + $script:paramBlock = $ast.ParamBlock + } + + It 'Should define a param block' { + $script:paramBlock | Should -Not -BeNullOrEmpty + } + + It 'Should expose a mandatory FileName parameter' { + $fileNameParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'FileName' + } + $fileNameParam | Should -Not -BeNullOrEmpty + + $mandatoryAttr = $fileNameParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.AttributeAst] -and + $_.TypeName.Name -eq 'Parameter' + } + $mandatoryArg = $mandatoryAttr.NamedArguments | + Where-Object { $_.ArgumentName -eq 'Mandatory' } + $mandatoryArg | Should -Not -BeNullOrEmpty + } + + It 'Should type FileName as System.String' { + $fileNameParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'FileName' + } + $typeAttr = $fileNameParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.TypeConstraintAst] + } + $typeAttr.TypeName.FullName | Should -Be 'System.String' + } + + It 'Should expose a Clean switch parameter' { + $cleanParam = $script:paramBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq 'Clean' + } + $cleanParam | Should -Not -BeNullOrEmpty + + $typeAttr = $cleanParam.Attributes | Where-Object { + $_ -is [System.Management.Automation.Language.TypeConstraintAst] + } + $typeAttr.TypeName.FullName | Should -Be 'switch' + } + + It 'Should have exactly two parameters' { + $script:paramBlock.Parameters.Count | Should -Be 2 + } +} + +Describe 'SPSCleanDependencies.ps1 Module Imports' { + + It 'Should import the helper module SPSCleanDependencies.util.psm1' { + $script:scriptContent | Should -Match 'Import-Module[^\n]*SPSCleanDependencies\.util\.psm1' + } + + It 'Should import the SqlServer module' { + $script:scriptContent | Should -Match 'Import-Module\s+-Name\s+SqlServer' + } +} + +Describe 'SPSCleanDependencies.ps1 Logs/Results Bootstrapping' { + + It 'Should create the Logs folder if it does not exist' { + $script:scriptContent | Should -Match "New-Item[^\n]+-Name\s+'Logs'" + } + + It 'Should create the Results folder if it does not exist' { + $script:scriptContent | Should -Match "New-Item[^\n]+-Name\s+'Results'" + } + + It 'Should start a transcript' { + $script:scriptContent | Should -Match 'Start-Transcript\s+-Path\s+\$pathLogFile' + } + + It 'Should stop the transcript at the end' { + $script:scriptContent | Should -Match 'Stop-Transcript' + } +} + +Describe 'SPSCleanDependencies.ps1 Clean Branch' { + + It 'Should call Get-SPSMissingServerDependencies when -Clean is not specified' { + $script:scriptContent | Should -Match 'Get-SPSMissingServerDependencies\s+-Path\s+\$pathJsonFile' + } + + It 'Should read the JSON results file with Test-Path / ConvertFrom-Json' { + $script:scriptContent | Should -Match 'Test-Path\s+\$pathJsonFile' + $script:scriptContent | Should -Match 'ConvertFrom-Json' + } + + It 'Should call Remove-SPSMissingFeature in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingFeature\s+-Database' + } + + It 'Should call Remove-SPSMissingSetupFile in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingSetupFile\s+-Database' + } + + It 'Should call Remove-SPSMissingAssembly in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingAssembly\s+-Database' + } + + It 'Should call Remove-SPSMissingConfiguration in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingConfiguration\s+-Database' + } + + It 'Should call Remove-SPSMissingWebPart in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSMissingWebPart\s+-Database' + } + + It 'Should call Remove-SPSOrphanedSite in the Clean branch' { + $script:scriptContent | Should -Match 'Remove-SPSOrphanedSite\s+-Database' + } + + It 'Should throw when the JSON results file is missing' { + $script:scriptContent | Should -Match 'Throw\s+"Missing\s+\$pathJsonFile' + } +} From 8f43bcdd0aeb1949cd13ddb17198d4291f43ae5b Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 12 Jun 2026 10:18:44 +0200 Subject: [PATCH 2/4] Refactor import-time prelude in SPSCleanDependencies.util.psm1 for improved error handling and testing; update tests to skip prelude on non-SharePoint hosts. --- .../Modules/SPSCleanDependencies.util.psm1 | 49 +++++++++++-------- .../SPSCleanDependencies.util.Tests.ps1 | 10 +++- 2 files changed, 37 insertions(+), 22 deletions(-) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 index b54c385..dab73e6 100644 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ b/scripts/Modules/SPSCleanDependencies.util.psm1 @@ -1,13 +1,7 @@ -# Ensure the script is running with administrator privileges -if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { - Throw "Administrator rights are required. Please re-run this script as an Administrator." -} -# Setting power management plan to High Performance" -Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow function Get-SPSInstalledProductVersion { [OutputType([System.Version])] param () - + $pathToSearch = 'C:\Program Files\Common Files\microsoft shared\Web Server Extensions\*\ISAPI\Microsoft.SharePoint.dll' $fullPath = Get-Item $pathToSearch -ErrorAction SilentlyContinue | Sort-Object { $_.Directory } -Descending | Select-Object -First 1 if ($null -eq $fullPath) { @@ -17,25 +11,38 @@ function Get-SPSInstalledProductVersion { return (Get-Command $fullPath).FileVersionInfo } } -# Load SharePoint Powershell Snapin or Import-Module -try { - $installedVersion = Get-SPSInstalledProductVersion - if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { - if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { - Add-PSSnapin Microsoft.SharePoint.PowerShell - } + +# Import-time prelude (admin check, power plan, SharePoint snap-in load). +# Skipped when $env:SPSCD_SKIP_PRELUDE is set, so the module can be imported on +# CI / non-SharePoint hosts (e.g. Pester) without elevation or SharePoint installed. +if (-not $env:SPSCD_SKIP_PRELUDE) { + # Ensure the script is running with administrator privileges + if (-NOT ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator')) { + Throw "Administrator rights are required. Please re-run this script as an Administrator." } - else { - Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking + # Setting power management plan to High Performance" + Start-Process -FilePath "$env:SystemRoot\system32\powercfg.exe" -ArgumentList '/s 8c5e7fda-e8bf-4a96-9a85-a6e23a8c635c' -NoNewWindow + + # Load SharePoint Powershell Snapin or Import-Module + try { + $installedVersion = Get-SPSInstalledProductVersion + if ($installedVersion.ProductMajorPart -eq 15 -or $installedVersion.ProductBuildPart -le 12999) { + if ($null -eq (Get-PSSnapin -Name Microsoft.SharePoint.PowerShell -ErrorAction SilentlyContinue)) { + Add-PSSnapin Microsoft.SharePoint.PowerShell + } + } + else { + Import-Module SharePointServer -Verbose:$false -WarningAction SilentlyContinue -DisableNameChecking + } } -} -catch { - # Handle errors during retrieval of Installed Product Version - $catchMessage = @" + catch { + # Handle errors during retrieval of Installed Product Version + $catchMessage = @" Failed to get installed Product Version for $($env:COMPUTERNAME) Exception: $($_.Exception.Message) "@ - Write-Error -Message $catchMessage + Write-Error -Message $catchMessage + } } # Initialize jSON Object diff --git a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 index c9b41ab..72a979a 100644 --- a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 +++ b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 @@ -5,6 +5,11 @@ BeforeAll { $repoRoot = Split-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -Parent $script:modulePath = Join-Path -Path $repoRoot -ChildPath 'scripts/Modules/SPSCleanDependencies.util.psm1' + # Skip the module's import-time prelude (admin check, powercfg, SharePoint snap-in load), + # which only makes sense on a real SharePoint farm. + $script:previousSkipPrelude = $env:SPSCD_SKIP_PRELUDE + $env:SPSCD_SKIP_PRELUDE = '1' + # Stub SharePoint cmdlets so the module can be imported on non-Windows / no-SharePoint hosts. # Real behaviour is exercised on a SharePoint farm; these tests only validate shape & contracts. $spsStubs = @( @@ -19,11 +24,14 @@ BeforeAll { } } - Import-Module -Name $script:modulePath -Force -DisableNameChecking -ErrorAction SilentlyContinue + # Surface real import errors instead of silently hiding them (which previously + # produced a cascade of misleading "$null or empty" failures). + Import-Module -Name $script:modulePath -Force -DisableNameChecking } AfterAll { Remove-Module -Name 'SPSCleanDependencies.util' -Force -ErrorAction SilentlyContinue + $env:SPSCD_SKIP_PRELUDE = $script:previousSkipPrelude } Describe 'SPSCleanDependencies.util.psm1 Module' { From 0b4e674391f3cd28c755c3c045f353800f1d0009 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 12 Jun 2026 10:38:34 +0200 Subject: [PATCH 3/4] Enhance cleanup functions with SupportsShouldProcess and improve error handling; update tests to verify ShouldProcess support. --- CHANGELOG.md | 13 +++++- RELEASE-NOTES.md | 9 +++- .../Modules/SPSCleanDependencies.util.psm1 | 46 +++++++++++++++---- .../SPSCleanDependencies.util.Tests.ps1 | 19 ++++++++ 4 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 525e7a7..8d70b81 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations (SiteID/WebID/ListID/DirName/LeafName). - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). - Extend `SPMissingWebPartInfo` class with `ClassName`, `StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`. + - All `Remove-SPS*` functions now declare `[CmdletBinding(SupportsShouldProcess = $true)]` and gate destructive calls with `$PSCmdlet.ShouldProcess`, enabling `-WhatIf` / `-Confirm` for every cleanup branch. + - Import-time prelude (admin check, `powercfg`, SharePoint snap-in load) is now gated behind the `SPSCD_SKIP_PRELUDE` environment variable so the module can be imported on non-SharePoint hosts (CI, Pester) without elevation or SharePoint installed. Behaviour on a real SharePoint farm is unchanged. - SPSCleanDependencies.ps1: - Implement the `MissingWebPart` cleanup branch (previously a no-op). @@ -18,11 +20,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Pester test suite under `tests/`: - `tests/SPSCleanDependencies.Tests.ps1` - script-level tests (metadata, parameters, module imports, Clean branch wiring). - - `tests/Modules/SPSCleanDependencies.util.Tests.ps1` - module-level tests (public/SQL function contracts, class shapes, safety net for empty `StorageKey`). + - `tests/Modules/SPSCleanDependencies.util.Tests.ps1` - module-level tests (public/SQL function contracts, class shapes, safety net for empty `StorageKey`, `SupportsShouldProcess` coverage on every `Remove-SPS*` function). - CI: - `.github/workflows/pester.yml` - runs Pester 5.3+ on `windows-latest` for pull requests to `main`, plus a `PSScriptAnalyzer` code-quality job. +### Fixed + +- SPSCleanDependencies.util.psm1: + - Replace `Write-Host` in `Remove-SPSMissingSetupFile` with `Write-Output` (PSScriptAnalyzer `PSAvoidUsingWriteHost`). + - Suppress `PSUseSingularNouns` on the public `Get-SPSMissingServerDependencies` function (name preserved for backward compatibility). + +- tests/Modules/SPSCleanDependencies.util.Tests.ps1: + - Surface real `Import-Module` failures instead of silently swallowing them with `-ErrorAction SilentlyContinue`, which had been hiding the actual cause of cascading test failures on CI. + ## [1.1.0] - 2025-10-21 ### Changed diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 12921ca..1005b7f 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -8,13 +8,20 @@ - Add `Get-SQLMissingWebPartInfo` helper to resolve missing WebPart class IDs to per-page locations. - Add `Remove-SPSMissingWebPart` cleanup function (uses `GetLimitedWebPartManager` and temporarily clears the site `ReadOnly` flag). - Extend `SPMissingWebPartInfo` class with location fields (`StorageKey`, `SiteID`, `WebID`, `ListID`, `DirName`, `LeafName`). + - All `Remove-SPS*` functions now support `-WhatIf` / `-Confirm` via `SupportsShouldProcess`. + - Import-time prelude (admin check, `powercfg`, SharePoint snap-in load) is now gated behind the `SPSCD_SKIP_PRELUDE` environment variable so the module can be imported on CI / non-SharePoint hosts. - SPSCleanDependencies.ps1: - Implement the `MissingWebPart` cleanup branch (previously a no-op). - Implement the `SiteOrphan` cleanup branch by wiring up the existing `Remove-SPSOrphanedSite` function. -- Pester test suite under `tests/` covering the script and helper module. +- Pester test suite under `tests/` covering the script and helper module, including `SupportsShouldProcess` coverage on every `Remove-SPS*` function. - `.github/workflows/pester.yml` CI workflow running Pester 5.3+ and `PSScriptAnalyzer` on `windows-latest`. +### Fixed + +- Replace `Write-Host` with `Write-Output` in `Remove-SPSMissingSetupFile` and clear remaining `PSScriptAnalyzer` warnings. +- Stop hiding real `Import-Module` failures in the module Pester tests so future regressions surface immediately. + A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 index dab73e6..e7aeed4 100644 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ b/scripts/Modules/SPSCleanDependencies.util.psm1 @@ -328,6 +328,8 @@ SELECT [tp_SiteID],[tp_Login] FROM [UserInfo] WITH (NOLOCK) WHERE tp_IsActive = return $tbSQLmissingConfigurations } +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', + Justification = 'Public function name kept for backward compatibility with existing callers and documentation.')] function Get-SPSMissingServerDependencies { param ( @@ -541,6 +543,7 @@ function Get-SPSMissingServerDependencies { } function Remove-SPSMissingFeature { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -571,7 +574,9 @@ function Remove-SPSMissingFeature { ForEach ($web in $Site.AllWebs) { if ($web.Features[$featureID]) { Write-Output "`nFound Feature $featureID in web:"$Web.Url"`nRemoving feature" - $web.Features.Remove($featureID, $true) + if ($PSCmdlet.ShouldProcess($Web.Url, "Remove feature $featureID")) { + $web.Features.Remove($featureID, $true) + } } else { Write-Output "`nDid not find feature $featureID in web:" $Web.Url @@ -580,7 +585,9 @@ function Remove-SPSMissingFeature { #Remove the feature from the site collection if ($Site.Features[$featureID]) { Write-Output "`nFound feature $featureID in site:"$site.Url"`nRemoving Feature" - $site.Features.Remove($featureID, $true) + if ($PSCmdlet.ShouldProcess($site.Url, "Remove feature $featureID")) { + $site.Features.Remove($featureID, $true) + } } else { Write-Output "Did not find feature $featureID in site:" $site.Url @@ -596,6 +603,7 @@ function Remove-SPSMissingFeature { } function Remove-SPSMissingSetupFile { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -633,9 +641,11 @@ function Remove-SPSMissingSetupFile { $file = $web.GetFile([GUID]$FileID) if ($null -ne $file.ServerRelativeUrl) { $filelocation = "{0}{1}" -f ($site.WebApplication.Url).TrimEnd("/"), $file.ServerRelativeUrl - Write-Host "Found file location:" $filelocation + Write-Output "Found file location: $filelocation" #Delete the file, the Delete() method bypasses the recycle bin - $file.Delete() + if ($PSCmdlet.ShouldProcess($filelocation, "Delete setup file $FileID")) { + $file.Delete() + } $web.dispose() $site.dispose() } @@ -657,6 +667,7 @@ function Remove-SPSMissingSetupFile { } function Remove-SPSMissingWebPart { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -723,7 +734,9 @@ function Remove-SPSMissingWebPart { $webPartToDelete = $spWebPartManager.WebParts | Where-Object -FilterScript { $_.StorageKey -eq $StorageKey } if ($null -ne $webPartToDelete) { Write-Output "Removing WebPart with StorageKey $StorageKey from page" - $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) + if ($PSCmdlet.ShouldProcess($pageUrl, "Delete WebPart with StorageKey $StorageKey")) { + $spWebPartManager.DeleteWebPart($spWebPartManager.WebParts[$webPartToDelete.Id]) + } } else { Write-Output "WebPart with StorageKey $StorageKey not found on page" @@ -752,6 +765,7 @@ function Remove-SPSMissingWebPart { } function Remove-SPSMissingAssembly { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -800,7 +814,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPSite object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPSite $SiteID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPSite object." @@ -820,7 +836,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPWeb object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPWeb $WebID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPWeb object." @@ -842,7 +860,9 @@ function Remove-SPSMissingAssembly { } if ($null -ne $AssemblyToDelete) { Write-Output "Removing AssemblyID $AssemblyID from SPList object." - $AssemblyToDelete.delete() + if ($PSCmdlet.ShouldProcess("SPList $hostID", "Delete AssemblyID $AssemblyID")) { + $AssemblyToDelete.delete() + } } else { Write-Output "AssemblyID $AssemblyID does not exist in SPList object." @@ -865,6 +885,7 @@ function Remove-SPSMissingAssembly { } function Remove-SPSMissingConfiguration { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -899,7 +920,9 @@ function Remove-SPSMissingConfiguration { Write-Output "Removing login: $Login" Write-Output "of SiteAdministrator Property of SPWeb:" Write-Output "$($web.Url)" - $web.SiteAdministrators.Remove($Login) + if ($PSCmdlet.ShouldProcess($web.Url, "Remove SiteAdministrator $Login")) { + $web.SiteAdministrators.Remove($Login) + } } else { Write-Output "$Login does not exist in SiteAdministrators Property" @@ -922,6 +945,7 @@ function Remove-SPSMissingConfiguration { } function Remove-SPSOrphanedSite { + [CmdletBinding(SupportsShouldProcess = $true)] param ( [Parameter()] @@ -943,7 +967,9 @@ function Remove-SPSOrphanedSite { $spContentDb = Get-SPContentDatabase $Database if ($null -ne $spContentDb) { Write-Output "Removing SPSite object $SiteID with the method ForceDeleteSite" - $spContentDb.ForceDeleteSite($siteID, $false, $false) + if ($PSCmdlet.ShouldProcess("SiteID $SiteID in database $Database", 'ForceDeleteSite')) { + $spContentDb.ForceDeleteSite($siteID, $false, $false) + } } else { Write-Output "SPContentDatabse $Database does not exist.`nPlease check this Database" diff --git a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 index 72a979a..c781ddf 100644 --- a/tests/Modules/SPSCleanDependencies.util.Tests.ps1 +++ b/tests/Modules/SPSCleanDependencies.util.Tests.ps1 @@ -154,6 +154,25 @@ Describe 'Remove-SPSMissingWebPart Safety Net' { } } +Describe 'SPSCleanDependencies.util.psm1 ShouldProcess Support' { + + $stateChangingFunctions = @( + 'Remove-SPSMissingFeature', + 'Remove-SPSMissingSetupFile', + 'Remove-SPSMissingWebPart', + 'Remove-SPSMissingAssembly', + 'Remove-SPSMissingConfiguration', + 'Remove-SPSOrphanedSite' + ) + + It '<_> declares SupportsShouldProcess' -ForEach $stateChangingFunctions { + $cmd = Get-Command -Name $_ -ErrorAction Stop + # CmdletBinding(SupportsShouldProcess) injects -WhatIf and -Confirm into the function. + $cmd.Parameters.Keys | Should -Contain 'WhatIf' + $cmd.Parameters.Keys | Should -Contain 'Confirm' + } +} + Describe 'SPSCleanDependencies.util.psm1 Class Definitions' { BeforeAll { From eaa656d1b8e6f171965a27f851467cc9effb7fd9 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Fri, 12 Jun 2026 10:46:30 +0200 Subject: [PATCH 4/4] Add PSScriptAnalyzerSettings.psd1 to configure script analysis; update CI workflow to use custom settings and adjust changelog and release notes for backward compatibility. --- .github/workflows/pester.yml | 2 +- CHANGELOG.md | 4 +++- PSScriptAnalyzerSettings.psd1 | 12 ++++++++++++ RELEASE-NOTES.md | 2 +- scripts/Modules/SPSCleanDependencies.util.psm1 | 2 -- 5 files changed, 17 insertions(+), 5 deletions(-) create mode 100644 PSScriptAnalyzerSettings.psd1 diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index f57b2fe..a430d86 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -71,7 +71,7 @@ jobs: $scriptFiles = Get-ChildItem -Path ./scripts -Include '*.ps1', '*.psm1' -Recurse $results = $scriptFiles | ForEach-Object { - Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning + Invoke-ScriptAnalyzer -Path $_.FullName -Severity Error,Warning -Settings ./PSScriptAnalyzerSettings.psd1 } if ($results) { diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d70b81..cf5e6e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,7 +29,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - SPSCleanDependencies.util.psm1: - Replace `Write-Host` in `Remove-SPSMissingSetupFile` with `Write-Output` (PSScriptAnalyzer `PSAvoidUsingWriteHost`). - - Suppress `PSUseSingularNouns` on the public `Get-SPSMissingServerDependencies` function (name preserved for backward compatibility). + +- CI / repo configuration: + - Add `PSScriptAnalyzerSettings.psd1` at the repo root, excluding `PSUseSingularNouns` so the public `Get-SPSMissingServerDependencies` function can keep its current (plural) name for backward compatibility. The CI workflow now invokes `Invoke-ScriptAnalyzer` with `-Settings ./PSScriptAnalyzerSettings.psd1`. - tests/Modules/SPSCleanDependencies.util.Tests.ps1: - Surface real `Import-Module` failures instead of silently swallowing them with `-ErrorAction SilentlyContinue`, which had been hiding the actual cause of cascading test failures on CI. diff --git a/PSScriptAnalyzerSettings.psd1 b/PSScriptAnalyzerSettings.psd1 new file mode 100644 index 0000000..0dae8e2 --- /dev/null +++ b/PSScriptAnalyzerSettings.psd1 @@ -0,0 +1,12 @@ +@{ + # Settings consumed by Invoke-ScriptAnalyzer in CI and the local lint task. + # + # PSUseSingularNouns is excluded because the public function + # Get-SPSMissingServerDependencies must keep its current (plural) name + # for backward compatibility with existing callers, documentation, and + # the wiki. Attribute-based per-function suppression is unreliable for + # this rule, so we exclude it project-wide. + ExcludeRules = @( + 'PSUseSingularNouns' + ) +} diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 1005b7f..8c25f68 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -21,7 +21,7 @@ ### Fixed -- Replace `Write-Host` with `Write-Output` in `Remove-SPSMissingSetupFile` and clear remaining `PSScriptAnalyzer` warnings. +- Replace `Write-Host` with `Write-Output` in `Remove-SPSMissingSetupFile` and clear remaining `PSScriptAnalyzer` warnings via a new `PSScriptAnalyzerSettings.psd1` at the repo root (excluding `PSUseSingularNouns` to preserve the public `Get-SPSMissingServerDependencies` name). - Stop hiding real `Import-Module` failures in the module Pester tests so future regressions surface immediately. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Modules/SPSCleanDependencies.util.psm1 b/scripts/Modules/SPSCleanDependencies.util.psm1 index e7aeed4..7c75832 100644 --- a/scripts/Modules/SPSCleanDependencies.util.psm1 +++ b/scripts/Modules/SPSCleanDependencies.util.psm1 @@ -328,8 +328,6 @@ SELECT [tp_SiteID],[tp_Login] FROM [UserInfo] WITH (NOLOCK) WHERE tp_IsActive = return $tbSQLmissingConfigurations } -[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseSingularNouns', '', - Justification = 'Public function name kept for backward compatibility with existing callers and documentation.')] function Get-SPSMissingServerDependencies { param (