diff --git a/CHANGELOG.md b/CHANGELOG.md index 170415c..a96dbe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] - Working on updates to replace PlatyPS documentation creation with the new Microsoft.PowerShell.PlatyPS module. (PRs and other help would be welcomed!) -- CI: Release-and-Publish now auto-triggers only on changes to the published module bundle (`src/DLLPickle/**`, `src/DLLPickle.Build/DLLPickle.csproj`, `src/DLLPickle.Build/packages.lock.json`). CI-, policy-, docs-, test-, and tooling-only changes no longer publish a new PowerShell Gallery version; release a genuine packaging-logic change via a manual `workflow_dispatch` run. + +## [2.2.0] - 2026-06-02 + +> Adds proactive detection of the Az.Storage + ExchangeOnlineManagement OData incompatibility (#174) and a public conflict check, plus release-pipeline and CI hardening. The bundled assemblies are **unchanged** from 2.1.2. + +### Added + +- **`Test-DPLibraryConflict`** — a public cmdlet that reports known-incompatible module combinations loaded in the current session (with the reason, the separate-process workaround, and the issue link) and returns the active conflicts. +- **`Import-DPLibrary` now warns** when a known-conflicting module pair is — or later becomes — co-loaded: immediately if both are already imported, otherwise via a best-effort, idempotent, self-unregistering `AssemblyLoad` handler that fires only once the whole pair is co-loaded (armed only when both modules are installed). Advisory: it never throws and is skipped under Constrained Language Mode. +- Data-driven **`knownConflicts`** in `build/dependency-policy.json`, shipped into the module as `KnownConflicts.json` (adding a future conflict is a data edit, not a code change). +- Runtime ALC-ownership probe tooling under `tools/` (`Get-DLLPickleLoadedTrackedAssembly.ps1`; `Get-DLLPickleRuntimeAssemblySnapshot` now sources its filter from `trackedAssemblies`, so it captures the OData stack) used to adjudicate #174. + +### Changed + +- **Release-and-Publish auto-triggers only on changes to the published bundle** (`src/DLLPickle/**`, `src/DLLPickle.Build/DLLPickle.csproj`, `src/DLLPickle.Build/packages.lock.json`). CI-, policy-, docs-, test-, and tooling-only changes no longer publish a new PowerShell Gallery version; release a genuine packaging-logic change via a manual `workflow_dispatch` run. +- The Upstream-Compatibility gate workflows now **always report** (a lightweight change-detection job skips the heavy work on non-bundle PRs), so they can be configured as **required status checks** without deadlocking docs-/CI-only PRs; the matrix build's required-check target is the always-present aggregate **`Build gate`**. + +### Documentation + +- Documented the **Az.Storage + ExchangeOnlineManagement known limitation** (#174): the two modules bundle incompatible strong-named `Microsoft.OData.Core` versions (7.6.4 vs 7.22.0) into the default `AssemblyLoadContext` and **cannot share one process** in either import order. DLLPickle cannot resolve this by preloading (preloading either version breaks the other), so the OData stack stays `block` — now runtime-confirmed. Use the two modules in separate PowerShell processes; #174 remains open as an upstream incompatibility. +- Synced `docs/Architecture.md` (ALC self-isolation model, preload/block taxonomy, the required-checks model, and the multi-TFM / Windows PowerShell 5.1 notes). ## [2.1.2] - 2026-06-01 @@ -304,7 +324,8 @@ Full Changelog: [v0.2.5...v0.2.6](https://github.com/SamErde/DLLPickle/compare/v - Initial release. -[Unreleased]: https://github.com/SamErde/DLLPickle/compare/v2.1.2...HEAD +[Unreleased]: https://github.com/SamErde/DLLPickle/compare/v2.2.0...HEAD +[2.2.0]: https://github.com/SamErde/DLLPickle/tag/v2.2.0 [2.1.2]: https://github.com/SamErde/DLLPickle/tag/v2.1.2 [2.1.1]: https://github.com/SamErde/DLLPickle/tag/v2.1.1 [2.1.0]: https://github.com/SamErde/DLLPickle/tag/v2.1.0 diff --git a/build/DLLPickle.Build.ps1 b/build/DLLPickle.Build.ps1 index 3e5ab7e..dc9e848 100644 --- a/build/DLLPickle.Build.ps1 +++ b/build/DLLPickle.Build.ps1 @@ -836,6 +836,16 @@ Add-BuildTask CopyModuleFiles -After RestoreDependencies -Before Build { } #CopyModuleFiles +# Synopsis: Ship the policy's knownConflicts list into the module for the runtime conflict warning +Add-BuildTask ExportKnownConflicts -After CopyModuleFiles { + Write-Build Gray ' Exporting knownConflicts to the module output...' + $PolicyPath = Join-Path -Path $script:ProjectRoot -ChildPath 'build/dependency-policy.json' + $OutputPath = Join-Path -Path $script:ModuleOutputPath -ChildPath 'KnownConflicts.json' + & (Join-Path -Path $PSScriptRoot -ChildPath 'Export-DLLPickleKnownConflicts.ps1') -PolicyPath $PolicyPath -OutputPath $OutputPath + Write-Build Gray ' ...knownConflicts exported.' +} + + # Synopsis: Copies module assets to Artifacts folder Add-BuildTask AssetCopy -After CopyModuleFiles -Before Build { Write-Build Gray ' Copying assets to Artifacts...' diff --git a/build/Export-DLLPickleKnownConflicts.ps1 b/build/Export-DLLPickleKnownConflicts.ps1 new file mode 100644 index 0000000..ccc6df4 --- /dev/null +++ b/build/Export-DLLPickleKnownConflicts.ps1 @@ -0,0 +1,31 @@ +<# +.SYNOPSIS + Extracts the knownConflicts array from the dependency policy into a standalone JSON file shipped + with the module, so the runtime conflict-warning can read it (the full policy is not shipped). +.PARAMETER PolicyPath + Path to dependency-policy.json. +.PARAMETER OutputPath + Path to write the extracted knownConflicts JSON (an array). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$PolicyPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' + +$Policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json +$Conflicts = @($Policy.knownConflicts) + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} + +ConvertTo-Json -InputObject $Conflicts -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM diff --git a/build/dependency-policy.json b/build/dependency-policy.json index dedb6af..9b9a69c 100644 --- a/build/dependency-policy.json +++ b/build/dependency-policy.json @@ -70,6 +70,22 @@ ] ] }, + "knownConflicts": [ + { + "id": "174-odata-azstorage-exo", + "modules": [ "Az.Storage", "ExchangeOnlineManagement" ], + "assembly": "Microsoft.OData.Core", + "issue": "174", + "reason": "Az.Storage force-loads Microsoft.OData.Core 7.6.4 at import; ExchangeOnlineManagement's Get-EXO* cmdlets require 7.22.0. Both target the default ALC and are strong-named, so the two versions cannot coexist in one process - both import orders fail.", + "workaround": "Use Az.Storage and ExchangeOnlineManagement (Get-EXO* cmdlets) in separate PowerShell processes - for example a background job (Start-Job) or a second pwsh. A separate runspace in the same process does NOT help: the conflict is process-wide (one default AssemblyLoadContext per process).", + "evidence": { + "versions": { "Az.Storage": "7.6.4", "ExchangeOnlineManagement": "7.22.0" }, + "alc": "Default", + "runtimeProbe": "2026-06-01 scenarios 1-4: both load OData into the Default ALC; Az.Storage-first -> EXO REF_DEF_MISMATCH (0x80131040); EXO-first -> Az.Storage 'same name already loaded'.", + "decidedOn": "2026-06-01" + } + } + ], "baseline": { "capturedOn": "2026-05-31", "moduleVersions": { @@ -362,32 +378,44 @@ { "packageName": "Microsoft.OData.Core", "assemblyName": "Microsoft.OData.Core", + "classification": "block", "sourceModules": [ "ExchangeOnlineManagement", "Az.Storage" ], "updateMode": "reportOnly", - "reason": "Issue #174 showed that default OData preloading can break Az.Storage when ExchangeOnlineManagement and Az.Storage require incompatible OData identities." + "reason": "Issue #174 showed that default OData preloading can break Az.Storage when ExchangeOnlineManagement and Az.Storage require incompatible OData identities.", + "evidence": { + "basis": "Az.Storage 9.6.1 ships Microsoft.OData.Core 7.6.4; ExchangeOnlineManagement 3.9.2 requires 7.22.0. Both load into the default ALC; no preload version can satisfy both. See knownConflicts 174-odata-azstorage-exo for the runtime adjudication." + } }, { "packageName": "Microsoft.OData.Edm", "assemblyName": "Microsoft.OData.Edm", + "classification": "block", "sourceModules": [ "ExchangeOnlineManagement", "Az.Storage" ], "updateMode": "reportOnly", - "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented." + "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented.", + "evidence": { + "basis": "OData family dependency paired with Microsoft.OData.Core; same default-ALC conflict as Core. See knownConflicts 174-odata-azstorage-exo for the runtime adjudication." + } }, { "packageName": "Microsoft.Spatial", "assemblyName": "Microsoft.Spatial", + "classification": "block", "sourceModules": [ "ExchangeOnlineManagement", "Az.Storage" ], "updateMode": "reportOnly", - "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented." + "reason": "Keep the OData family out of the default preload set unless a future isolation strategy is implemented.", + "evidence": { + "basis": "OData family dependency paired with Microsoft.OData.Core; same default-ALC conflict as Core. See knownConflicts 174-odata-azstorage-exo for the runtime adjudication." + } } ] } diff --git a/docs/Deep-Dive.md b/docs/Deep-Dive.md index eeea525..76f3e44 100644 --- a/docs/Deep-Dive.md +++ b/docs/Deep-Dive.md @@ -162,6 +162,26 @@ exclusion for environment-specific troubleshooting. - [Module reference](DLLPickle.md) - [Dependency policy and compatibility notes](../DEPENDENCIES.md) +## Known limitation: Az.Storage + ExchangeOnlineManagement (issue #174) + +`Az.Storage` and `ExchangeOnlineManagement` bundle **incompatible, strong-named versions of +`Microsoft.OData.Core`** (7.6.4 and 7.22.0 respectively) and both load it into the default +`AssemblyLoadContext`. Only one version can exist per process, and **neither import order works**: + +- Import `Az.Storage` first, then run `Get-EXO*` → fails (`Could not load … Microsoft.OData.Core, + Version=7.22.0.0 … manifest definition does not match`). +- Import `ExchangeOnlineManagement`/`Connect-ExchangeOnline` first, then import `Az.Storage` → fails + (`Microsoft.OData.Core, Version=7.6.4.0 … assembly with same name is already loaded`). + +This is an upstream incompatibility between the two modules; **DLLPickle cannot fix it by preloading** +(preloading either version breaks the other module), which is why the OData assemblies are +classified `block`. DLLPickle warns when it detects both modules loaded (see `Test-DPLibraryConflict`). + +**Workaround:** use the two modules in **separate PowerShell processes** — for example, run `Get-EXO*` +work in one `pwsh` (or a `Start-Job` background job) and `Az.Storage` work in another. A separate +**runspace** in the *same* process does **not** help: the conflict is process-wide (one default +`AssemblyLoadContext` per process). + ## Audio Discussion [![Listen](https://raw.githubusercontent.com/SamErde/DLLPickle/main/assets/DLL_Pickle__A_Clever_Fix.png)](https://raw.githubusercontent.com/SamErde/DLLPickle/main/assets/DLL_Pickle__Interactive_Deep_Dive_audio.mp4) diff --git a/docs/superpowers/plans/2026-06-01-issue174-conflict-warning.md b/docs/superpowers/plans/2026-06-01-issue174-conflict-warning.md new file mode 100644 index 0000000..8b0da28 --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-issue174-conflict-warning.md @@ -0,0 +1,853 @@ +# Issue #174 Phase 2 — Conflict Detection & Warning Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Warn DLLPickle users about the Az.Storage ↔ ExchangeOnlineManagement OData incompatibility, driven by a data-defined `knownConflicts` list, and record the runtime evidence + workaround. + +**Architecture:** Conflicts are data in `build/dependency-policy.json`; the build extracts them into a shipped `module/DLLPickle/KnownConflicts.json`; a pure Private detector compares them against the session's loaded modules; a public `Test-DPLibraryConflict` and an `Import-DPLibrary`-armed one-shot `AssemblyLoad` handler surface a `Write-Warning`. The warning never throws. + +**Tech Stack:** PowerShell 7.4+, `System.AppDomain.AssemblyLoad`, Invoke-Build, Pester 5. + +**Spec:** `docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md`. Branch: `feat/issue174-conflict-warning`. + +**Release note:** this changes `src/DLLPickle/**` (a real module change) → merging triggers a PSGallery release. Use `feat:` commits so the bump is a minor. + +--- + +## File Structure + +- **Modify** `build/dependency-policy.json` — add top-level `knownConflicts` (+ #174 entry); cross-reference it from the OData `blockedPreloadAssemblies` evidence. +- **Create** `build/Export-DLLPickleKnownConflicts.ps1` — reusable extraction: policy → `KnownConflicts.json`. One responsibility, so the build and the test share it. +- **Modify** `build/DLLPickle.Build.ps1` — call the extractor after `CopyModuleFiles`. +- **Create** `src/DLLPickle/Private/Get-DPKnownConflict.ps1` — read the shipped `KnownConflicts.json` (or `-Path`), return the array; `@()` on missing/malformed. +- **Create** `src/DLLPickle/Private/Test-DPModuleConflict.ps1` — pure detector: `(-Conflict, -LoadedModule)` → active conflicts. +- **Create** `src/DLLPickle/Private/Format-DPConflictWarning.ps1` — `(-Conflict)` → warning message string. +- **Create** `src/DLLPickle/Private/Invoke-DPConflictCheck.ps1` — `Import-DPLibrary` glue: immediate-warn loaded pairs, arm one-shot handlers for installed pairs; CLM-guarded; never throws. +- **Create** `src/DLLPickle/Public/Test-DPLibraryConflict.ps1` — public entry: read + detect + warn; returns active conflicts. +- **Modify** `src/DLLPickle/DLLPickle.psd1` — add `Test-DPLibraryConflict` to `FunctionsToExport`. +- **Modify** `src/DLLPickle/Public/Import-DPLibrary.ps1` — call `Invoke-DPConflictCheck` near the end (try/catch). +- **Modify** `docs/Deep-Dive.md` — known-limitation section. +- **Create** `tests/Unit/KnownConflicts.Tests.ps1` — extractor + reader + detector + formatter + public-function tests. +- **Modify** `tests/Integration/DLLPickle.Issue174.OData.Tests.ps1` — assert both import orders fail. + +All function names use approved, non-state-changing verbs (`Get`/`Test`/`Format`/`Invoke`) to stay `Analyze`/`AnalyzeTests`/`AnalyzeTools`-clean. + +--- + +## Task 1: Add `knownConflicts` to the policy + +**Files:** +- Modify: `build/dependency-policy.json` + +- [ ] **Step 1: Add the `knownConflicts` array** + +Add a top-level `"knownConflicts"` member to `build/dependency-policy.json` (e.g., immediately after the `"baseline"` object — valid anywhere at top level): + +```json + "knownConflicts": [ + { + "id": "174-odata-azstorage-exo", + "modules": [ "Az.Storage", "ExchangeOnlineManagement" ], + "assembly": "Microsoft.OData.Core", + "issue": "174", + "reason": "Az.Storage force-loads Microsoft.OData.Core 7.6.4 at import; ExchangeOnlineManagement's Get-EXO* cmdlets require 7.22.0. Both target the default ALC and are strong-named, so the two versions cannot coexist in one process - both import orders fail.", + "workaround": "Use Az.Storage and ExchangeOnlineManagement (Get-EXO* cmdlets) in separate PowerShell sessions or processes (for example, run one in a background job or a separate runspace/pwsh).", + "evidence": { + "versions": { "Az.Storage": "7.6.4", "ExchangeOnlineManagement": "7.22.0" }, + "alc": "Default", + "runtimeProbe": "2026-06-01 scenarios 1-4: both load OData into the Default ALC; Az.Storage-first -> EXO REF_DEF_MISMATCH (0x80131040); EXO-first -> Az.Storage 'same name already loaded'.", + "decidedOn": "2026-06-01" + } + } + ] +``` + +- [ ] **Step 2: Cross-reference from the OData block evidence** + +In the three `Microsoft.OData.*`/`Microsoft.Spatial` entries under `blockedPreloadAssemblies`, append to each `evidence.basis` string: ` See knownConflicts 174-odata-azstorage-exo for the runtime adjudication.` (Keep `classification`/`updateMode` as `block`/`reportOnly`.) + +- [ ] **Step 3: Verify the JSON parses and the entry is well-formed** + +Run: +```bash +pwsh -NoProfile -Command "$p = Get-Content ./build/dependency-policy.json -Raw | ConvertFrom-Json; $k = $p.knownConflicts | Where-Object id -eq '174-odata-azstorage-exo'; if (-not $k) { throw 'missing' }; @($k.modules) -join ',' " +``` +Expected: `Az.Storage,ExchangeOnlineManagement` (and no parse error). + +- [ ] **Step 4: Commit** + +```bash +git add build/dependency-policy.json +git commit -m "feat(policy): add knownConflicts (#174 Az.Storage+EXO OData) + OData evidence cross-ref" +``` + +--- + +## Task 2: Ship `knownConflicts` to the module at build time + +**Files:** +- Create: `build/Export-DLLPickleKnownConflicts.ps1` +- Modify: `build/DLLPickle.Build.ps1` +- Test: `tests/Unit/KnownConflicts.Tests.ps1` + +- [ ] **Step 1: Write the failing extractor test** + +Create `tests/Unit/KnownConflicts.Tests.ps1`: + +```powershell +BeforeAll { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $ExportScript = Join-Path $RepoRoot 'build\Export-DLLPickleKnownConflicts.ps1' + $PolicyPath = Join-Path $RepoRoot 'build\dependency-policy.json' +} + +Describe 'Export-DLLPickleKnownConflicts' -Tag 'Unit' { + It 'writes the policy knownConflicts array to the output file verbatim' { + $Out = Join-Path $TestDrive 'KnownConflicts.json' + & $ExportScript -PolicyPath $PolicyPath -OutputPath $Out + Test-Path -LiteralPath $Out | Should -BeTrue + $Written = Get-Content -LiteralPath $Out -Raw | ConvertFrom-Json + $Policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json + @($Written).Count | Should -Be @($Policy.knownConflicts).Count + ($Written | Where-Object id -EQ '174-odata-azstorage-exo') | Should -Not -BeNullOrEmpty + } +} +``` + +- [ ] **Step 2: Run it to verify it fails** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: FAIL — `Export-DLLPickleKnownConflicts.ps1` not found. + +- [ ] **Step 3: Create the extractor** + +Create `build/Export-DLLPickleKnownConflicts.ps1`: + +```powershell +<# +.SYNOPSIS + Extracts the knownConflicts array from the dependency policy into a standalone JSON file shipped + with the module, so the runtime conflict-warning can read it (the full policy is not shipped). +.PARAMETER PolicyPath + Path to dependency-policy.json. +.PARAMETER OutputPath + Path to write the extracted knownConflicts JSON (an array). +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$PolicyPath, + + [Parameter(Mandatory)] + [ValidateNotNullOrEmpty()] + [string]$OutputPath +) + +$ErrorActionPreference = 'Stop' + +$Policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json +$Conflicts = @($Policy.knownConflicts) + +$OutputDirectory = Split-Path -Path $OutputPath -Parent +if ($OutputDirectory -and -not (Test-Path -LiteralPath $OutputDirectory -PathType Container)) { + $null = New-Item -Path $OutputDirectory -ItemType Directory -Force +} + +ConvertTo-Json -InputObject $Conflicts -Depth 20 | Set-Content -LiteralPath $OutputPath -Encoding utf8NoBOM +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: PASS. + +- [ ] **Step 5: Wire the extractor into the build** + +In `build/DLLPickle.Build.ps1`, add this task after the `CopyModuleFiles` task definition (it runs as part of `PrepareModuleOutput`): + +```powershell +# Synopsis: Ship the policy's knownConflicts list into the module for the runtime conflict warning +Add-BuildTask ExportKnownConflicts -After CopyModuleFiles { + Write-Build Gray ' Exporting knownConflicts to the module output...' + $PolicyPath = Join-Path -Path $script:ProjectRoot -ChildPath 'build/dependency-policy.json' + $OutputPath = Join-Path -Path $script:ModuleOutputPath -ChildPath 'KnownConflicts.json' + & (Join-Path -Path $PSScriptRoot -ChildPath 'Export-DLLPickleKnownConflicts.ps1') -PolicyPath $PolicyPath -OutputPath $OutputPath + Write-Build Gray ' ...knownConflicts exported.' +} +``` + +- [ ] **Step 6: Verify the build ships the file** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task PrepareModuleOutput; Test-Path ./module/DLLPickle/KnownConflicts.json"` +Expected: ends with `True`, and the file contains the #174 conflict. + +- [ ] **Step 7: Commit** + +```bash +git add build/Export-DLLPickleKnownConflicts.ps1 build/DLLPickle.Build.ps1 tests/Unit/KnownConflicts.Tests.ps1 +git commit -m "feat(build): ship knownConflicts.json into the module output" +``` + +--- + +## Task 3: Detector, reader, and formatter (Private) + +**Files:** +- Create: `src/DLLPickle/Private/Test-DPModuleConflict.ps1` +- Create: `src/DLLPickle/Private/Get-DPKnownConflict.ps1` +- Create: `src/DLLPickle/Private/Format-DPConflictWarning.ps1` +- Test: `tests/Unit/KnownConflicts.Tests.ps1` + +- [ ] **Step 1: Write the failing detector + formatter tests** + +Append to `tests/Unit/KnownConflicts.Tests.ps1` (inside `BeforeAll`, add the dot-sources; then add the Describe blocks): + +In `BeforeAll`, after the existing lines, add: + +```powershell + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Test-DPModuleConflict.ps1') + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Get-DPKnownConflict.ps1') + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Format-DPConflictWarning.ps1') + + $SampleConflict = [PSCustomObject]@{ + id = 'sample'; modules = @('Alpha', 'Beta'); assembly = 'Some.Assembly'; issue = '999' + reason = 'Alpha and Beta clash.'; workaround = 'Use separate sessions.' + } +``` + +Append these Describe blocks: + +```powershell +Describe 'Test-DPModuleConflict' -Tag 'Unit' { + It 'returns a conflict when every module in the pair is loaded' { + $Active = Test-DPModuleConflict -Conflict @($SampleConflict) -LoadedModule @('Alpha', 'Beta', 'Gamma') + @($Active).Count | Should -Be 1 + $Active[0].id | Should -Be 'sample' + } + + It 'returns nothing when only one module in the pair is loaded' { + $Active = Test-DPModuleConflict -Conflict @($SampleConflict) -LoadedModule @('Alpha', 'Gamma') + @($Active) | Should -BeNullOrEmpty + } + + It 'returns nothing for an empty conflict list' { + $Active = Test-DPModuleConflict -Conflict @() -LoadedModule @('Alpha', 'Beta') + @($Active) | Should -BeNullOrEmpty + } +} + +Describe 'Format-DPConflictWarning' -Tag 'Unit' { + It 'includes the modules, workaround, and issue link' { + $Message = Format-DPConflictWarning -Conflict $SampleConflict + $Message | Should -Match 'Alpha' + $Message | Should -Match 'Beta' + $Message | Should -Match 'separate sessions' + $Message | Should -Match 'issues/999' + } +} + +Describe 'Get-DPKnownConflict' -Tag 'Unit' { + It 'reads conflicts from an explicit path' { + $Path = Join-Path $TestDrive 'kc.json' + ConvertTo-Json -InputObject @($SampleConflict) -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8 + $Conflicts = Get-DPKnownConflict -Path $Path + @($Conflicts).Count | Should -Be 1 + $Conflicts[0].id | Should -Be 'sample' + } + + It 'returns an empty array when the file is missing' { + $Conflicts = Get-DPKnownConflict -Path (Join-Path $TestDrive 'nope.json') + @($Conflicts) | Should -BeNullOrEmpty + } + + It 'returns an empty array (no throw) when the file is malformed' { + $Path = Join-Path $TestDrive 'bad.json' + Set-Content -LiteralPath $Path -Value '{ not json' -Encoding utf8 + { Get-DPKnownConflict -Path $Path } | Should -Not -Throw + @(Get-DPKnownConflict -Path $Path) | Should -BeNullOrEmpty + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: FAIL — the three Private scripts don't exist (dot-source errors). + +- [ ] **Step 3: Create `Test-DPModuleConflict.ps1`** + +```powershell +function Test-DPModuleConflict { + <# + .SYNOPSIS + Returns the known conflicts whose every module is currently loaded. + .DESCRIPTION + Pure comparison: given the knownConflicts data and the set of loaded module names, returns the + conflict entries where every module in the pair appears in the loaded set. No side effects. + .PARAMETER Conflict + The knownConflicts entries (each with a .modules string array). + .PARAMETER LoadedModule + The names of modules currently imported in the session. + .OUTPUTS + The subset of Conflict whose modules are all loaded. + #> + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Conflict, + + [Parameter()] + [string[]]$LoadedModule + ) + + process { + foreach ($Entry in @($Conflict)) { + $Modules = @($Entry.modules) + if ($Modules.Count -eq 0) { continue } + $AllLoaded = $true + foreach ($Name in $Modules) { + if ($LoadedModule -notcontains $Name) { $AllLoaded = $false; break } + } + if ($AllLoaded) { $Entry } + } + } +} +``` + +- [ ] **Step 4: Create `Format-DPConflictWarning.ps1`** + +```powershell +function Format-DPConflictWarning { + <# + .SYNOPSIS + Builds the user-facing warning message for a known module conflict. + .PARAMETER Conflict + A knownConflicts entry (modules, reason, workaround, issue). + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object]$Conflict + ) + + process { + $Modules = @($Conflict.modules) -join ' + ' + $Lines = @( + "DLLPickle: '$Modules' cannot be used together in one PowerShell session. $($Conflict.reason)" + "Workaround: $($Conflict.workaround)" + "Details: https://github.com/SamErde/DLLPickle/issues/$($Conflict.issue)" + ) + $Lines -join [System.Environment]::NewLine + } +} +``` + +- [ ] **Step 5: Create `Get-DPKnownConflict.ps1`** + +```powershell +function Get-DPKnownConflict { + <# + .SYNOPSIS + Reads the module's shipped knownConflicts data. + .DESCRIPTION + Loads KnownConflicts.json (shipped at the module root by the build) and returns the conflict + array. Returns an empty array - never throws - if the file is missing or malformed, so the + advisory warning can never break a session. + .PARAMETER Path + Optional path to a knownConflicts JSON file. Defaults to KnownConflicts.json at the module root + (this script lives in /Private, so the module root is its parent directory). + .OUTPUTS + The knownConflicts entries, or an empty array. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$Path + ) + + process { + if (-not $Path) { + $Path = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'KnownConflicts.json' + } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-Verbose "No knownConflicts file at '$Path'." + return @() + } + try { + return @((Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)) + } catch { + Write-Verbose "Could not parse knownConflicts at '$Path': $_" + return @() + } + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: PASS — all detector/formatter/reader tests green. + +- [ ] **Step 7: Run the analyzer** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze"` +Expected: PASS — no findings (approved verbs `Test`/`Format`/`Get`; no `Write-Host`). + +- [ ] **Step 8: Commit** + +```bash +git add src/DLLPickle/Private/Test-DPModuleConflict.ps1 src/DLLPickle/Private/Format-DPConflictWarning.ps1 src/DLLPickle/Private/Get-DPKnownConflict.ps1 tests/Unit/KnownConflicts.Tests.ps1 +git commit -m "feat(module): add knownConflicts reader, detector, and warning formatter (private)" +``` + +--- + +## Task 4: Public `Test-DPLibraryConflict` + +**Files:** +- Create: `src/DLLPickle/Public/Test-DPLibraryConflict.ps1` +- Modify: `src/DLLPickle/DLLPickle.psd1` +- Test: `tests/Unit/KnownConflicts.Tests.ps1` + +- [ ] **Step 1: Write the failing public-function tests** + +In `BeforeAll`, add the dot-source: + +```powershell + . (Join-Path $RepoRoot 'src\DLLPickle\Public\Test-DPLibraryConflict.ps1') +``` + +Append this Describe block (it uses two real, always-loaded modules so the loaded-check is true): + +```powershell +Describe 'Test-DPLibraryConflict' -Tag 'Unit' { + BeforeAll { + Import-Module Microsoft.PowerShell.Management -ErrorAction SilentlyContinue + Import-Module Microsoft.PowerShell.Utility -ErrorAction SilentlyContinue + $LoadedPairPath = Join-Path $TestDrive 'loaded-pair.json' + ConvertTo-Json -Depth 20 -InputObject @( + [PSCustomObject]@{ id = 'loaded'; modules = @('Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Utility'); assembly = 'x'; issue = '174'; reason = 'r'; workaround = 'w' } + ) | Set-Content -LiteralPath $LoadedPairPath -Encoding utf8 + $UnloadedPairPath = Join-Path $TestDrive 'unloaded-pair.json' + ConvertTo-Json -Depth 20 -InputObject @( + [PSCustomObject]@{ id = 'unloaded'; modules = @('No.Such.ModuleA', 'No.Such.ModuleB'); assembly = 'x'; issue = '174'; reason = 'r'; workaround = 'w' } + ) | Set-Content -LiteralPath $UnloadedPairPath -Encoding utf8 + } + + It 'warns and returns the conflict when both modules are loaded' { + $Active = Test-DPLibraryConflict -KnownConflictsPath $LoadedPairPath -WarningAction SilentlyContinue + @($Active).Count | Should -Be 1 + $Active[0].id | Should -Be 'loaded' + } + + It 'emits a Write-Warning when a conflict is active' { + $Warnings = $null + Test-DPLibraryConflict -KnownConflictsPath $LoadedPairPath -WarningVariable Warnings -WarningAction SilentlyContinue | Out-Null + @($Warnings).Count | Should -BeGreaterThan 0 + } + + It 'is silent and returns nothing when no conflict pair is fully loaded' { + $Warnings = $null + $Active = Test-DPLibraryConflict -KnownConflictsPath $UnloadedPairPath -WarningVariable Warnings -WarningAction SilentlyContinue + @($Active) | Should -BeNullOrEmpty + @($Warnings) | Should -BeNullOrEmpty + } +} +``` + +- [ ] **Step 2: Run to verify failure** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: FAIL — `Test-DPLibraryConflict` not defined. + +- [ ] **Step 3: Create `Test-DPLibraryConflict.ps1`** + +```powershell +function Test-DPLibraryConflict { + <# + .SYNOPSIS + Reports known module conflicts that are active in the current PowerShell session. + .DESCRIPTION + Compares DLLPickle's shipped knownConflicts list against the modules currently imported and + writes a warning for each conflict whose modules are all loaded together (a combination known + to fail, such as Az.Storage + ExchangeOnlineManagement sharing an incompatible Microsoft.OData + version). Returns the active conflict objects. Advisory only - never throws. + .PARAMETER KnownConflictsPath + Optional path to a knownConflicts JSON file. Defaults to the file shipped with the module. + .OUTPUTS + The active conflict entries (or nothing if none are active). + .EXAMPLE + Test-DPLibraryConflict + + Warns if any known-incompatible module combination is currently loaded. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$KnownConflictsPath + ) + + process { + $Conflicts = Get-DPKnownConflict -Path $KnownConflictsPath + $LoadedModule = @(Get-Module | Select-Object -ExpandProperty Name) + $Active = @(Test-DPModuleConflict -Conflict $Conflicts -LoadedModule $LoadedModule) + foreach ($Entry in $Active) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $Entry) + } + $Active + } +} +``` + +- [ ] **Step 4: Export the function in the manifest** + +In `src/DLLPickle/DLLPickle.psd1`, add `'Test-DPLibraryConflict'` to the `FunctionsToExport` array (alongside the existing entries). + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/KnownConflicts.Tests.ps1 -Output Detailed"` +Expected: PASS. + +- [ ] **Step 6: Run the analyzer** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze"` +Expected: PASS — no findings. + +- [ ] **Step 7: Commit** + +```bash +git add src/DLLPickle/Public/Test-DPLibraryConflict.ps1 src/DLLPickle/DLLPickle.psd1 tests/Unit/KnownConflicts.Tests.ps1 +git commit -m "feat(module): add public Test-DPLibraryConflict" +``` + +--- + +## Task 5: `Invoke-DPConflictCheck` + `Import-DPLibrary` integration + +**Files:** +- Create: `src/DLLPickle/Private/Invoke-DPConflictCheck.ps1` +- Modify: `src/DLLPickle/Public/Import-DPLibrary.ps1` + +- [ ] **Step 1: Create `Invoke-DPConflictCheck.ps1`** + +```powershell +function Invoke-DPConflictCheck { + <# + .SYNOPSIS + After preload, warns about (or arms a one-shot warning for) known incompatible module pairs. + .DESCRIPTION + For each known conflict: if every module is already loaded, warn immediately. Otherwise, if + every module is installed (so the clash can still happen later), register a single + AssemblyLoad handler that warns the first time the remaining module's assemblies load, then + unregisters itself. Advisory only: fully guarded, never throws, and skipped under Constrained + Language Mode (where the AppDomain APIs are unavailable). + .PARAMETER KnownConflictsPath + Optional override for the knownConflicts file (testing). Defaults to the shipped file. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$KnownConflictsPath + ) + + process { + try { + if ($ExecutionContext.SessionState.LanguageMode -eq [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage) { + Write-Verbose 'Constrained Language Mode: skipping conflict-watch arming.' + return + } + + $Conflicts = Get-DPKnownConflict -Path $KnownConflictsPath + if (@($Conflicts).Count -eq 0) { return } + + $LoadedNames = @(Get-Module | Select-Object -ExpandProperty Name) + $AvailableNames = @(Get-Module -ListAvailable | Select-Object -ExpandProperty Name -Unique) + + foreach ($Conflict in $Conflicts) { + $Modules = @($Conflict.modules) + if ($Modules.Count -eq 0) { continue } + + $LoadedCount = @($Modules | Where-Object { $LoadedNames -contains $_ }).Count + if ($LoadedCount -eq $Modules.Count) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $Conflict) + continue + } + + $AllInstalled = $true + foreach ($Name in $Modules) { + if ($AvailableNames -notcontains $Name) { $AllInstalled = $false; break } + } + if (-not $AllInstalled) { continue } + + # Watch the not-yet-loaded module(s): capture their installed base path(s) now, and warn + # the first time an assembly loads from one of them (meaning the pair is now co-loaded). + $WatchedBase = @( + $Modules | + Where-Object { $LoadedNames -notcontains $_ } | + ForEach-Object { Get-Module -ListAvailable -Name $_ | Sort-Object Version -Descending | Select-Object -First 1 -ExpandProperty ModuleBase } | + Where-Object { $_ } + ) + if ($WatchedBase.Count -eq 0) { continue } + + $State = [PSCustomObject]@{ Conflict = $Conflict; Bases = $WatchedBase; Handler = $null } + $State.Handler = [System.AssemblyLoadEventHandler]{ + param($EventSender, $LoadArgs) + try { + $Location = $LoadArgs.LoadedAssembly.Location + if ($Location) { + foreach ($Base in $State.Bases) { + if ($Location.StartsWith($Base, [System.StringComparison]::OrdinalIgnoreCase)) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $State.Conflict) + [System.AppDomain]::CurrentDomain.remove_AssemblyLoad($State.Handler) + break + } + } + } + } catch { + # Advisory only: never let the warning path disrupt assembly loading. + } + }.GetNewClosure() + [System.AppDomain]::CurrentDomain.add_AssemblyLoad($State.Handler) + } + } catch { + Write-Verbose "Conflict check skipped due to error: $_" + } + } +} +``` + +- [ ] **Step 2: Call it from `Import-DPLibrary`** + +In `src/DLLPickle/Public/Import-DPLibrary.ps1`, near the end of the function (after the preload work completes, before the function returns its results), add: + +```powershell + # Advisory: warn about known-incompatible module combinations (e.g. #174 Az.Storage + EXO). + try { + Invoke-DPConflictCheck + } catch { + Write-Verbose "Invoke-DPConflictCheck failed: $_" + } +``` + +(Place it inside the same scope where the rest of the import logic runs, guarded so it can never affect the import result.) + +- [ ] **Step 3: Verify the module imports and the function is callable** + +Run: +```bash +pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task PrepareModuleOutput; Import-Module ./module/DLLPickle/DLLPickle.psd1 -Force; Import-DPLibrary -SuppressLogo | Out-Null; 'ok'" +``` +Expected: ends with `ok` (no errors; the conflict check is a no-op unless both conflicting modules are present). + +- [ ] **Step 4: Run the analyzer** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze"` +Expected: PASS — no findings (`Invoke` is approved and not state-changing-flagged). + +- [ ] **Step 5: Commit** + +```bash +git add src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 src/DLLPickle/Public/Import-DPLibrary.ps1 +git commit -m "feat(module): warn on known module conflicts from Import-DPLibrary (immediate + armed handler)" +``` + +--- + +## Task 6: Known-limitation documentation + +**Files:** +- Modify: `docs/Deep-Dive.md` + +- [ ] **Step 1: Add a known-limitation section** + +Append a section to `docs/Deep-Dive.md`: + +```markdown +## Known limitation: Az.Storage + ExchangeOnlineManagement (issue #174) + +`Az.Storage` and `ExchangeOnlineManagement` bundle **incompatible, strong-named versions of +`Microsoft.OData.Core`** (7.6.4 and 7.22.0 respectively) and both load it into the default +`AssemblyLoadContext`. Only one version can exist per process, and **neither import order works**: + +- Import `Az.Storage` first, then run `Get-EXO*` → fails (`Could not load … Microsoft.OData.Core, + Version=7.22.0.0 … manifest definition does not match`). +- Import `ExchangeOnlineManagement`/`Connect-ExchangeOnline` first, then import `Az.Storage` → fails + (`Microsoft.OData.Core, Version=7.6.4.0 … assembly with same name is already loaded`). + +This is an upstream incompatibility between the two modules; **DLLPickle cannot fix it by preloading** +(preloading either version breaks the other module), which is why the OData assemblies are +classified `block`. DLLPickle warns when it detects both modules loaded (see `Test-DPLibraryConflict`). + +**Workaround:** use the two modules in **separate PowerShell sessions or processes** — for example, +run `Get-EXO*` work in one `pwsh`/runspace/background job and `Az.Storage` work in another. +``` + +- [ ] **Step 2: Commit** + +```bash +git add docs/Deep-Dive.md +git commit -m "docs: document the Az.Storage + EXO OData known limitation (#174)" +``` + +--- + +## Task 7: Extend the #174 repro test to both orders + +**Files:** +- Modify: `tests/Integration/DLLPickle.Issue174.OData.Tests.ps1` + +**Why a rework:** the current `Initialize-Issue174SyntheticModule` makes `Get-EXOMailbox` throw unless a real `Microsoft.OData.Core` 7.22 assembly is loaded (which never happens synthetically), so it can only model the Az.Storage-first failure. To model **both** orders, replace the helper with a single shared "OData slot" global (`$global:DPSyntheticODataVersion`) that simulates the one default-ALC OData identity: Az.Storage force-loads 7.6.4 **at import** (throws if a higher version already occupies the slot); EXO's `Get-EXOMailbox` lazily needs 7.22.0 (throws if the lower version is already in the slot, else takes the slot and succeeds). Each scenario runs in a fresh child `pwsh`, so the slot is naturally per-scenario. + +- [ ] **Step 1: Replace `Initialize-Issue174SyntheticModule`** + +Replace the entire `Initialize-Issue174SyntheticModule` function (in the file's `BeforeAll`) with: + +```powershell + function Initialize-Issue174SyntheticModule { + [CmdletBinding()] + param( + [Parameter(Mandatory)] + [string]$RootPath, + + [Parameter(Mandatory)] + [ValidateSet('Az.Storage', 'ExchangeOnlineManagement')] + [string]$Name, + + [Parameter(Mandatory)] + [string]$Version + ) + + $ModuleDirectory = Join-Path -Path $RootPath -ChildPath ([System.IO.Path]::Combine($Name, $Version)) + $null = New-Item -Path $ModuleDirectory -ItemType Directory -Force + $ModuleFile = Join-Path -Path $ModuleDirectory -ChildPath "$Name.psm1" + $ManifestFile = Join-Path -Path $ModuleDirectory -ChildPath "$Name.psd1" + + if ($Name -eq 'Az.Storage') { + @' +# Synthetic Az.Storage: at import it force-loads Microsoft.OData.Core 7.6.4 into the single shared +# OData slot. If a higher version (EXO 7.22.0) already holds the slot, the load collides. +if ($global:DPSyntheticODataVersion -and [version]$global:DPSyntheticODataVersion -gt [version]'7.6.4.0') { + throw [System.IO.FileNotFoundException]::new("Could not load file or assembly 'Microsoft.OData.Core, Version=7.6.4.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. Assembly with same name is already loaded") +} +$global:DPSyntheticODataVersion = '7.6.4.0' + +function New-AzStorageContext { + [CmdletBinding()] + param( + [Parameter()] [string]$StorageAccountName, + [Parameter()] [switch]$Anonymous + ) + [PSCustomObject]@{ StorageAccountName = $StorageAccountName } +} +Export-ModuleMember -Function New-AzStorageContext +'@ | Set-Content -LiteralPath $ModuleFile -Encoding UTF8 + } else { + @' +function Connect-ExchangeOnline { + [CmdletBinding()] + param( + [Parameter()] [switch]$ManagedIdentity, + [Parameter()] [string]$Organization + ) + [PSCustomObject]@{ Connected = $true; Organization = $Organization } +} + +# Synthetic EXO: Get-EXO* lazily needs Microsoft.OData.Core 7.22.0. If the lower 7.6.4 already holds +# the slot (Az.Storage imported first), the higher reference cannot bind; otherwise it takes the slot. +function Get-EXOMailbox { + [CmdletBinding()] + param( + [Parameter()] [int]$ResultSize + ) + if ($global:DPSyntheticODataVersion -and [version]$global:DPSyntheticODataVersion -lt [version]'7.22.0.0') { + throw [System.IO.FileNotFoundException]::new("Could not load file or assembly 'Microsoft.OData.Core, Version=7.22.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. The located assembly's manifest definition does not match the assembly reference. (0x80131040)") + } + $global:DPSyntheticODataVersion = '7.22.0.0' + [PSCustomObject]@{ DisplayName = 'Synthetic mailbox' } +} +Export-ModuleMember -Function Connect-ExchangeOnline, Get-EXOMailbox +'@ | Set-Content -LiteralPath $ModuleFile -Encoding UTF8 + } + + New-ModuleManifest -Path $ManifestFile -RootModule "$Name.psm1" -ModuleVersion $Version -FunctionsToExport '*' -ErrorAction Stop + } +``` + +The two existing tests keep their assertions and still pass with this model: in the Az.Storage→EXO order, Az.Storage takes the slot at 7.6.4, then `Get-EXOMailbox` sees 7.6.4 < 7.22 and throws a `Microsoft.OData.Core` error (their `Should -Match 'Microsoft.OData.Core'` still holds; the DLLPickle-preload test's `AssembliesAfter` check is about the real built module and is unaffected). + +- [ ] **Step 2: Add the EXO-first It block** + +Add to the `Describe 'Issue 174 …'` block: + +```powershell + It 'fails the EXO-first order too: importing Az.Storage after EXO is loaded throws' { + $Result = Invoke-DLLPickleScenario -Name 'Issue174-EXOThenAzStorage-Synthetic' ` + -ModuleManifestPath $BuiltModuleManifestPath ` + -AdditionalModulePath $SyntheticModuleRoot ` + -OutputPath (Join-Path $ScenarioOutputRoot 'Issue174-EXOThenAzStorage-Synthetic.json') ` + -Step @( + @{ Name = 'Import ExchangeOnlineManagement'; Script = 'Import-Module ExchangeOnlineManagement -Force' } + @{ Name = 'Connect ExchangeOnlineManagement'; Script = 'Connect-ExchangeOnline -ManagedIdentity -Organization synthetic.example' } + @{ Name = 'Get EXO Mailbox'; Script = 'Get-EXOMailbox' } + @{ Name = 'Import Az.Storage'; Script = 'Import-Module Az.Storage -Force' } + ) + + $Result.Success | Should -BeFalse + $MailboxStep = $Result.Steps | Where-Object Name -EQ 'Get EXO Mailbox' + $MailboxStep.Success | Should -BeTrue + $AzStorageStep = $Result.Steps | Where-Object Name -EQ 'Import Az.Storage' + $AzStorageStep.Success | Should -BeFalse + $AzStorageStep.Error.Message | Should -Match 'Microsoft.OData.Core' + } +``` + +(Note the EXO-first assertions: `Get-EXOMailbox` now **succeeds** — taking the slot at 7.22.0 — and the subsequent **Az.Storage import** fails. This is the inverse of the Az.Storage-first test.) + +- [ ] **Step 3: Run the issue-repro suite to verify both orders fail** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task IssueReproTest"` +Expected: PASS — the Az.Storage-first tests (Get-EXOMailbox fails) and the new EXO-first test (Az.Storage import fails) all assert the expected failures. + +- [ ] **Step 4: Commit** + +```bash +git add tests/Integration/DLLPickle.Issue174.OData.Tests.ps1 +git commit -m "test(#174): model both import orders with a shared OData slot (EXO-first fails on Az.Storage import)" +``` + +--- + +## Task 8: Validate the full gate and post the #174 findings + +**Files:** none (verification + coordination) + +- [ ] **Step 1: Run the full gate** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze,Test"` +Expected: PASS — analyzer clean on src/tests/tools; all unit tests pass (existing + the new `KnownConflicts.Tests.ps1`). + +- [ ] **Step 2: Smoke the public function against the shipped data** + +Run: +```bash +pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task PrepareModuleOutput; Import-Module ./module/DLLPickle/DLLPickle.psd1 -Force; Test-DPLibraryConflict -WarningAction SilentlyContinue | Format-Table id,modules" +``` +Expected: no error; returns nothing (unless both Az.Storage + EXO happen to be loaded). Confirms the shipped `KnownConflicts.json` is readable. + +- [ ] **Step 3: Post the findings comment on #174 (keep it open)** + +The controller posts a comment on issue #174 summarizing the Phase 1 runtime evidence (both orders fail; OData stays `block`), the workaround (separate sessions), and the new `Test-DPLibraryConflict` warning. Do **not** close the issue. This is a coordination step, not a code change. + +--- + +## Notes for the implementer + +- **Pester:** `Invoke-Pester -Path ` (Pester 5; the bootstrap installs it). The full suite runs via `Invoke-Build -Task Test`. +- **Analyzer:** `AnalyzeTests` excludes only `PSUseDeclaredVarsMoreThanAssignments`; `AnalyzeTools` excludes nothing. Every function here uses an approved, non-state-changing verb (`Get`/`Test`/`Format`/`Invoke`). No `Write-Host`. +- **Never throw:** the warning path is advisory. `Get-DPKnownConflict`, `Invoke-DPConflictCheck`, and the AssemblyLoad handler are all try/catch-guarded and degrade to silence. +- **CLM:** the armed handler uses `[AppDomain]`/`AssemblyLoadContext`; it is skipped under Constrained Language Mode. CI and normal `pwsh` run Full Language Mode. +- **Release:** `src/DLLPickle/**` changes here are a real module feature → merging triggers a PSGallery release (minor, from `feat:` commits). The runbook/probe tooling from Phase 1 is unaffected. diff --git a/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md b/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md new file mode 100644 index 0000000..2d5ccc0 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md @@ -0,0 +1,88 @@ +# Issue #174 Phase 2 — Conflict Detection & Warning Design + +**Goal:** Warn DLLPickle users about the Az.Storage ↔ ExchangeOnlineManagement OData incompatibility (which DLLPickle cannot fix by preloading), driven by a data-defined `knownConflicts` list, plus record the runtime evidence and document the workaround. + +**Architecture:** Conflicts are declared as data in `build/dependency-policy.json`; the build ships a small extracted copy into the module; a Private detector compares that data against the session's loaded modules; a public `Test-DPLibraryConflict` and an `Import-DPLibrary`-registered one-shot `AssemblyLoad` handler surface a warning when a conflicting pair becomes co-loaded. + +**Tech Stack:** PowerShell 7.4+, `System.AppDomain.AssemblyLoad`, Invoke-Build (`PrepareModuleOutput`), Pester 5. + +**Predecessor:** `docs/superpowers/specs/2026-06-01-issue174-odata-alc-probe-design.md` (Phase 1 probe). Phase 1 runtime evidence (2026-06-01): Az.Storage force-loads `Microsoft.OData.Core` 7.6.4 at import; EXO's `Get-EXO*` require 7.22.0; both target the **default ALC** and are strong-named; **both import orders fail** (Az.Storage-first → EXO `REF_DEF_MISMATCH`; EXO-first → Az.Storage "assembly with same name is already loaded"). Verdict: not fixable by preloading — OData stays `block`. This phase records that and warns users. + +--- + +## 1. Scope + +In scope: the `knownConflicts` data model, its runtime availability, detection, the public function, the `Import-DPLibrary` warning integration, the OData evidence cross-reference, the known-limitation doc, the extended #174 test, and a findings comment on #174 (kept **open**). + +Out of scope: closing #174 (keep open per maintainer); Stage 2b auth automation; any attempt to *fix* the conflict (e.g., private-ALC isolation) — ruled out by Phase 1. + +## 2. `knownConflicts` data model (`build/dependency-policy.json`) + +New top-level array `knownConflicts`. Each entry: + +```json +{ + "id": "174-odata-azstorage-exo", + "modules": ["Az.Storage", "ExchangeOnlineManagement"], + "assembly": "Microsoft.OData.Core", + "issue": "174", + "reason": "Az.Storage force-loads Microsoft.OData.Core 7.6.4 at import; ExchangeOnlineManagement's Get-EXO* cmdlets require 7.22.0. Both target the default ALC and are strong-named, so the two versions cannot coexist in one process - both import orders fail.", + "workaround": "Use Az.Storage and ExchangeOnlineManagement (Get-EXO* cmdlets) in separate PowerShell processes - a background job (Start-Job) or a second pwsh. A separate runspace in the same process does NOT help: the conflict is process-wide (one default AssemblyLoadContext per process).", + "evidence": { + "versions": { "Az.Storage": "7.6.4", "ExchangeOnlineManagement": "7.22.0" }, + "alc": "Default", + "runtimeProbe": "2026-06-01 scenarios 1-4: both load OData into the Default ALC; Az.Storage-first -> EXO REF_DEF_MISMATCH (0x80131040); EXO-first -> Az.Storage 'same name already loaded'.", + "decidedOn": "2026-06-01" + } +} +``` + +This is the single source of truth. The matching `blockedPreloadAssemblies` OData entries gain a one-line cross-reference (`see knownConflicts 174-odata-azstorage-exo`) and remain `block`. + +## 3. Runtime availability (build ships an extracted subset) + +`build/dependency-policy.json` is a build/CI artifact and is **not** shipped in the module. So a build step extracts just `knownConflicts` and writes it to the module output as `module/DLLPickle/KnownConflicts.json` (during `PrepareModuleOutput`, after `CopyModuleFiles`). The runtime reads that shipped file (resolved relative to the module root). A unit test asserts the shipped subset equals the policy's `knownConflicts` (sync guard). Rationale: one source (the policy), small shipped payload (only the conflict list, not the full policy). + +## 4. Detection (Private `Test-DPModuleConflict`) + +A Private, side-effect-free helper. Input: the `knownConflicts` array (from the shipped file) and the set of currently-imported module names (`Get-Module | Select Name`). Output: the conflict entries whose **every** `modules` member is currently loaded. Pure and unit-testable (caller injects the conflict data + a module-name list). + +## 5. Public `Test-DPLibraryConflict` + +New exported function (added to the manifest `FunctionsToExport`). Reads the shipped `KnownConflicts.json` (via an optional `-KnownConflictsPath` parameter defaulting to the shipped module location — the parameter exists for testability so a test can point at synthetic data in `TestDrive`), runs `Test-DPModuleConflict` against the current session, and emits one `Write-Warning` per active conflict: the reason, the workaround, and `https://github.com/SamErde/DLLPickle/issues/`. Returns the active conflict objects (so it is also usable programmatically). Comment-based help with examples. Runnable anytime the user suspects a conflict. + +## 6. `Import-DPLibrary` integration + +After the preload completes, for each known conflict (all wrapped so a detection failure can never throw into the user's load path): + +- If **every** module in the pair is already **loaded** → `Write-Warning` immediately (reuse `Test-DPLibraryConflict`'s warning text). +- Else if **every** module is **installed** (`Get-Module -ListAvailable`) but not all loaded → the clash is possible later, so **arm a one-shot handler**: register a single `[System.AppDomain]::CurrentDomain.add_AssemblyLoad(...)` handler. At arm time, capture the `ModuleBase` path(s) of the not-yet-loaded conflict module(s). The handler checks the **loaded assembly's path** (the event arg's `Assembly.Location`) against those base paths (a cheap string check — no cmdlet calls inside the load callback); when an assembly from the watched module loads (meaning the pair is now co-loaded), it emits the warning **once** and **unregisters itself**. +- **Guards:** skip arming under Constrained Language Mode (`$ExecutionContext.SessionState.LanguageMode -eq 'ConstrainedLanguage'` — the AppDomain APIs are blocked there); track handled conflict `id`s in a module-scoped (`$script:`) set so a second `Import-DPLibrary` call does not re-warn or stack handlers; query availability only for the conflict's own module names (not all of PSModulePath); normalize paths before the prefix check; the handler body is wrapped in try/catch and never throws. + +**Best-effort limits (acknowledged):** the auto-warn is advisory, not a guarantee. A *rejected* assembly load raises no `AssemblyLoad` event, so if the second module's import fails outright before any of its assemblies load (the EXO-first → Az.Storage-import-failure order), the handler may not fire — the user sees the raw error. Detection keys on imported modules (`Get-Module`), so a module removed with `Remove-Module` while its assemblies remain resident is not re-detected. `Test-DPLibraryConflict` (on demand) and the documented separate-process workaround are the reliable paths; assembly-level detection is a possible future enhancement. + +This catches every order automatically while arming only when both modules are installed (no overhead otherwise). + +## 7. Supporting items + +- **OData `blockedPreloadAssemblies` evidence:** add the cross-reference to the knownConflicts entry; keep `block`. +- **Known-limitation doc:** a short section in `docs/Deep-Dive.md` (and/or `DEPENDENCIES.md`) describing the Az.Storage + EXO OData limitation and the separate-session workaround, linking #174. +- **#174 synthetic test:** extend `tests/Integration/DLLPickle.Issue174.OData.Tests.ps1` to also assert the **EXO-first → Az.Storage import fails** order (the current test only covers Az.Storage-first), so both-orders-fail is the recorded characterization. +- **#174 issue:** post the Phase 1 evidence + this resolution as a comment; **keep the issue open**. + +## 8. Error handling + +The warning is advisory and must never degrade the session: all detection/handler code is `try/catch`-guarded and emits at most a `Write-Warning` (never throws, never writes errors). A missing or malformed `KnownConflicts.json` → no warning (and a `Write-Verbose` note), not a failure. The armed handler is idempotent and self-unregisters after firing. + +## 9. Testing + +- **Unit:** `Test-DPModuleConflict` (synthetic knownConflicts + injected loaded-module-name lists → returns the right active conflicts / none — the detector takes both as parameters, so no real modules needed); `Test-DPLibraryConflict` (via `-KnownConflictsPath` pointing at a synthetic file in `TestDrive` with a pair of always-loaded modules like `Microsoft.PowerShell.Management`/`Microsoft.PowerShell.Utility` → asserts a warning is emitted; and a non-loaded pair → asserts silence); the **extractor test** — run `Export-DLLPickleKnownConflicts` to a `TestDrive` path and assert its output equals `dependency-policy.json`'s `knownConflicts` (this validates the extraction against the policy without reading the build-generated `module/KnownConflicts.json`, so it does not depend on build order — the `Clean`/`Test` phases run before `PrepareModuleOutput`). Helpers use approved verbs (`Get-`/`Test-`) to stay `AnalyzeTests`-clean. +- **Integration:** the extended #174 both-orders repro. +- **Import-DPLibrary wiring:** the immediate-warn and armed-handler branches are thin glue over the unit-tested detector; the armed-handler auto-warn path is validated by the maintainer's live probe scenarios rather than a synthetic AssemblyLoad test (to avoid registering real session handlers in CI). + +## 10. Success criteria + +- A user who has both modules loaded sees a clear, actionable warning (reason + separate-session workaround + #174 link) — via `Test-DPLibraryConflict` on demand, immediately from `Import-DPLibrary` if already co-loaded, or automatically the moment the second module loads after `Import-DPLibrary`. +- The conflict is data-defined (`knownConflicts`); adding a future conflict is a data edit + a sync-test update, no new code. +- OData remains `block` with the runtime evidence recorded; the known limitation is documented; #174 carries the findings and stays open. +- All gates green (`Invoke-Build Analyze,Test`); the warning never throws. diff --git a/src/DLLPickle/DLLPickle.psd1 b/src/DLLPickle/DLLPickle.psd1 index 3f0951c..23a5347 100644 --- a/src/DLLPickle/DLLPickle.psd1 +++ b/src/DLLPickle/DLLPickle.psd1 @@ -72,10 +72,10 @@ FormatsToProcess = 'DLLPickle.Format.ps1xml' # NestedModules = @() # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. -FunctionsToExport = 'Get-DPConfig', 'Set-DPConfig', 'Find-DLLInPSModulePath', - 'Get-ModuleImportCandidate', 'Get-ModulesWithDependency', - 'Get-ModulesWithVersionSortedIdentityClient', 'Import-DPLibrary', - 'Import-DPBaseProfile' +FunctionsToExport = 'Get-DPConfig', 'Set-DPConfig', 'Find-DLLInPSModulePath', + 'Get-ModuleImportCandidate', 'Get-ModulesWithDependency', + 'Get-ModulesWithVersionSortedIdentityClient', 'Import-DPLibrary', + 'Import-DPBaseProfile', 'Test-DPLibraryConflict' # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() diff --git a/src/DLLPickle/Private/Format-DPConflictWarning.ps1 b/src/DLLPickle/Private/Format-DPConflictWarning.ps1 new file mode 100644 index 0000000..760b26e --- /dev/null +++ b/src/DLLPickle/Private/Format-DPConflictWarning.ps1 @@ -0,0 +1,26 @@ +function Format-DPConflictWarning { + <# + .SYNOPSIS + Builds the user-facing warning message for a known module conflict. + .PARAMETER Conflict + A knownConflicts entry (modules, reason, workaround, issue). + .OUTPUTS + System.String + #> + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory)] + [object]$Conflict + ) + + process { + $Modules = @($Conflict.modules) -join ' + ' + $Lines = @( + "DLLPickle: '$Modules' cannot be used together in one PowerShell session. $($Conflict.reason)" + "Workaround: $($Conflict.workaround)" + "Details: https://github.com/SamErde/DLLPickle/issues/$($Conflict.issue)" + ) + $Lines -join [System.Environment]::NewLine + } +} diff --git a/src/DLLPickle/Private/Get-DPKnownConflict.ps1 b/src/DLLPickle/Private/Get-DPKnownConflict.ps1 new file mode 100644 index 0000000..60928ef --- /dev/null +++ b/src/DLLPickle/Private/Get-DPKnownConflict.ps1 @@ -0,0 +1,36 @@ +function Get-DPKnownConflict { + <# + .SYNOPSIS + Reads the module's shipped knownConflicts data. + .DESCRIPTION + Loads KnownConflicts.json (shipped at the module root by the build) and returns the conflict + array. Returns an empty array - never throws - if the file is missing or malformed, so the + advisory warning can never break a session. + .PARAMETER Path + Optional path to a knownConflicts JSON file. Defaults to KnownConflicts.json at the module root + (this script lives in /Private, so the module root is its parent directory). + .OUTPUTS + The knownConflicts entries, or an empty array. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$Path + ) + + process { + if (-not $Path) { + $Path = Join-Path -Path (Split-Path -Path $PSScriptRoot -Parent) -ChildPath 'KnownConflicts.json' + } + if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) { + Write-Verbose "No knownConflicts file at '$Path'." + return @() + } + try { + return @((Get-Content -LiteralPath $Path -Raw | ConvertFrom-Json)) + } catch { + Write-Verbose "Could not parse knownConflicts at '$Path': $_" + return @() + } + } +} diff --git a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 new file mode 100644 index 0000000..a3a01db --- /dev/null +++ b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 @@ -0,0 +1,127 @@ +function Invoke-DPConflictCheck { + <# + .SYNOPSIS + After preload, warns about (or arms a best-effort one-shot warning for) known incompatible + module pairs. + .DESCRIPTION + For each known conflict: if every module is already loaded, warn immediately. Otherwise, if + every module is installed (so the clash can still happen later), register a single AssemblyLoad + handler that warns only once every module in the pair has actually been co-loaded, then + unregisters itself. + + Best-effort and advisory: it is fully guarded and never throws, is skipped under Constrained + Language Mode, and warns at most once per conflict per session. It cannot pre-empt a module + whose import fails outright before any of its assemblies load (a rejected load raises no + AssemblyLoad event), and it keys on imported modules, so a module removed with Remove-Module + after its assemblies are already resident is not re-detected. Test-DPLibraryConflict is the + reliable on-demand check; the authoritative protection is the separate-process workaround. + .PARAMETER KnownConflictsPath + Optional override for the knownConflicts file (testing). Defaults to the shipped file. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$KnownConflictsPath + ) + + process { + try { + if ($ExecutionContext.SessionState.LanguageMode -eq [System.Management.Automation.PSLanguageMode]::ConstrainedLanguage) { + Write-Verbose 'Constrained Language Mode: skipping conflict-watch arming.' + return + } + + $Conflicts = Get-DPKnownConflict -Path $KnownConflictsPath + if (@($Conflicts).Count -eq 0) { return } + + # Warn/arm at most once per conflict per session: repeated Import-DPLibrary calls must not + # stack AssemblyLoad handlers or re-emit the same warning. Module-scoped so it persists. + if (-not $script:DPConflictHandled) { + $script:DPConflictHandled = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + } + + $LoadedNames = @(Get-Module | Select-Object -ExpandProperty Name) + + foreach ($Conflict in $Conflicts) { + $Modules = @($Conflict.modules) + if ($Modules.Count -eq 0) { continue } + + $ConflictId = [string]$Conflict.id + if ($ConflictId -and $script:DPConflictHandled.Contains($ConflictId)) { continue } + + $LoadedCount = @($Modules | Where-Object { $LoadedNames -contains $_ }).Count + if ($LoadedCount -eq $Modules.Count) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $Conflict) + if ($ConflictId) { [void]$script:DPConflictHandled.Add($ConflictId) } + continue + } + + # Arm a watch for the not-yet-loaded module(s). For each, collect ALL installed version + # base paths (a version-pinned import may load from a non-latest copy). Query only these + # specific module names, not all of PSModulePath. Arm only when every one is installed. + $NotLoaded = @($Modules | Where-Object { $LoadedNames -notcontains $_ }) + $WatchedModule = [System.Collections.Generic.List[object]]::new() + $AllInstalled = $true + foreach ($Name in $NotLoaded) { + # Normalize each base to end with a directory separator so the handler's StartsWith + # check is a true directory-prefix match (e.g. '...\Az.Storage\' must not match a + # sibling '...\Az.Storage.Custom\'). + $Bases = @( + Get-Module -ListAvailable -Name $Name | + ForEach-Object { + if ($_.ModuleBase) { + $Full = [System.IO.Path]::GetFullPath($_.ModuleBase) + if (-not $Full.EndsWith([System.IO.Path]::DirectorySeparatorChar)) { + $Full += [System.IO.Path]::DirectorySeparatorChar + } + $Full + } + } | + Select-Object -Unique + ) + if ($Bases.Count -eq 0) { $AllInstalled = $false; break } + $WatchedModule.Add([PSCustomObject]@{ Name = $Name; Bases = $Bases }) + } + if (-not $AllInstalled -or $WatchedModule.Count -eq 0) { continue } + + # The handler marks a watched module "seen" when an assembly loads from any of its base + # paths, and warns only once EVERY watched module has been seen (i.e. the whole pair is + # co-loaded) - not when just one of them loads. + $State = [PSCustomObject]@{ + Conflict = $Conflict + Watched = $WatchedModule.ToArray() + Seen = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) + Handler = $null + } + $State.Handler = [System.AssemblyLoadEventHandler]{ + param($EventSender, $LoadArgs) + [void]$EventSender + try { + $Location = $LoadArgs.LoadedAssembly.Location + if ($Location) { + $FullLocation = [System.IO.Path]::GetFullPath($Location) + foreach ($Module in $State.Watched) { + foreach ($Base in $Module.Bases) { + if ($FullLocation.StartsWith($Base, [System.StringComparison]::OrdinalIgnoreCase)) { + [void]$State.Seen.Add($Module.Name) + break + } + } + } + if ($State.Seen.Count -ge $State.Watched.Count) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $State.Conflict) + [System.AppDomain]::CurrentDomain.remove_AssemblyLoad($State.Handler) + } + } + } catch { + Write-Verbose "AssemblyLoad conflict-watch handler error (advisory, suppressed): $_" + } + }.GetNewClosure() + [System.AppDomain]::CurrentDomain.add_AssemblyLoad($State.Handler) + if ($ConflictId) { [void]$script:DPConflictHandled.Add($ConflictId) } + } + } catch { + Write-Verbose "Conflict check skipped due to error: $_" + } + } +} diff --git a/src/DLLPickle/Private/Test-DPModuleConflict.ps1 b/src/DLLPickle/Private/Test-DPModuleConflict.ps1 new file mode 100644 index 0000000..3ec6f7b --- /dev/null +++ b/src/DLLPickle/Private/Test-DPModuleConflict.ps1 @@ -0,0 +1,35 @@ +function Test-DPModuleConflict { + <# + .SYNOPSIS + Returns the known conflicts whose every module is currently loaded. + .DESCRIPTION + Pure comparison: given the knownConflicts data and the set of loaded module names, returns the + conflict entries where every module in the pair appears in the loaded set. No side effects. + .PARAMETER Conflict + The knownConflicts entries (each with a .modules string array). + .PARAMETER LoadedModule + The names of modules currently imported in the session. + .OUTPUTS + The subset of Conflict whose modules are all loaded. + #> + [CmdletBinding()] + param( + [Parameter()] + [object[]]$Conflict, + + [Parameter()] + [string[]]$LoadedModule + ) + + process { + foreach ($Entry in @($Conflict)) { + $Modules = @($Entry.modules) + if ($Modules.Count -eq 0) { continue } + $AllLoaded = $true + foreach ($Name in $Modules) { + if ($LoadedModule -notcontains $Name) { $AllLoaded = $false; break } + } + if ($AllLoaded) { $Entry } + } + } +} diff --git a/src/DLLPickle/Public/Import-DPLibrary.ps1 b/src/DLLPickle/Public/Import-DPLibrary.ps1 index c63fbbb..5114bd3 100644 --- a/src/DLLPickle/Public/Import-DPLibrary.ps1 +++ b/src/DLLPickle/Public/Import-DPLibrary.ps1 @@ -490,5 +490,12 @@ } } + # Advisory: warn about known-incompatible module combinations (e.g. #174 Az.Storage + EXO). + try { + Invoke-DPConflictCheck + } catch { + Write-Verbose "Invoke-DPConflictCheck failed: $_" + } + $Results } diff --git a/src/DLLPickle/Public/Test-DPLibraryConflict.ps1 b/src/DLLPickle/Public/Test-DPLibraryConflict.ps1 new file mode 100644 index 0000000..8e4b936 --- /dev/null +++ b/src/DLLPickle/Public/Test-DPLibraryConflict.ps1 @@ -0,0 +1,34 @@ +function Test-DPLibraryConflict { + <# + .SYNOPSIS + Reports known module conflicts that are active in the current PowerShell session. + .DESCRIPTION + Compares DLLPickle's shipped knownConflicts list against the modules currently imported and + writes a warning for each conflict whose modules are all loaded together (a combination known + to fail, such as Az.Storage + ExchangeOnlineManagement sharing an incompatible Microsoft.OData + version). Returns the active conflict objects. Advisory only - never throws. + .PARAMETER KnownConflictsPath + Optional path to a knownConflicts JSON file. Defaults to the file shipped with the module. + .OUTPUTS + The active conflict entries (or nothing if none are active). + .EXAMPLE + Test-DPLibraryConflict + + Warns if any known-incompatible module combination is currently loaded. + #> + [CmdletBinding()] + param( + [Parameter()] + [string]$KnownConflictsPath + ) + + process { + $Conflicts = Get-DPKnownConflict -Path $KnownConflictsPath + $LoadedModule = @(Get-Module | Select-Object -ExpandProperty Name) + $Active = @(Test-DPModuleConflict -Conflict $Conflicts -LoadedModule $LoadedModule) + foreach ($Entry in $Active) { + Write-Warning -Message (Format-DPConflictWarning -Conflict $Entry) + } + $Active + } +} diff --git a/tests/Integration/DLLPickle.Issue174.OData.Tests.ps1 b/tests/Integration/DLLPickle.Issue174.OData.Tests.ps1 index b46d8cc..3876ee4 100644 --- a/tests/Integration/DLLPickle.Issue174.OData.Tests.ps1 +++ b/tests/Integration/DLLPickle.Issue174.OData.Tests.ps1 @@ -27,60 +27,47 @@ BeforeAll { if ($Name -eq 'Az.Storage') { @' -$global:DllPickleSyntheticODataCore = [PSCustomObject]@{ - Name = 'Microsoft.OData.Core' - Version = '7.6.4.0' - Source = 'Az.Storage' +# Synthetic Az.Storage: at import it force-loads Microsoft.OData.Core 7.6.4 into the single shared +# OData slot. If a higher version (EXO 7.22.0) already holds the slot, the load collides. +if ($global:DPSyntheticODataVersion -and [version]$global:DPSyntheticODataVersion -gt [version]'7.6.4.0') { + throw [System.IO.FileNotFoundException]::new("Could not load file or assembly 'Microsoft.OData.Core, Version=7.6.4.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. Assembly with same name is already loaded") } -'@ | Set-Content -LiteralPath $ModuleFile -Encoding UTF8 - } else { - @' -function Test-DLLPickleSyntheticAssemblyVersion { +$global:DPSyntheticODataVersion = '7.6.4.0' + +function New-AzStorageContext { [CmdletBinding()] param( - [Parameter(Mandatory)] - [string]$Name, - - [Parameter(Mandatory)] - [version]$MinimumVersion + [Parameter()] [string]$StorageAccountName, + [Parameter()] [switch]$Anonymous ) - - $LoadedAssembly = [System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -eq $Name } | - Sort-Object -Property { $_.GetName().Version } -Descending | - Select-Object -First 1 - - return $LoadedAssembly -and $LoadedAssembly.GetName().Version -ge $MinimumVersion + [PSCustomObject]@{ StorageAccountName = $StorageAccountName } } - +Export-ModuleMember -Function New-AzStorageContext +'@ | Set-Content -LiteralPath $ModuleFile -Encoding UTF8 + } else { + @' function Connect-ExchangeOnline { [CmdletBinding()] param( - [Parameter()] - [switch]$ManagedIdentity, - - [Parameter()] - [string]$Organization + [Parameter()] [switch]$ManagedIdentity, + [Parameter()] [string]$Organization ) - - [PSCustomObject]@{ - Connected = $true - ManagedIdentity = [bool]$ManagedIdentity - Organization = $Organization - } + [PSCustomObject]@{ Connected = $true; Organization = $Organization } } +# Synthetic EXO: Get-EXO* lazily needs Microsoft.OData.Core 7.22.0. If the lower 7.6.4 already holds +# the slot (Az.Storage imported first), the higher reference cannot bind; otherwise it takes the slot. function Get-EXOMailbox { [CmdletBinding()] - param() - - if (-not (Test-DLLPickleSyntheticAssemblyVersion -Name 'Microsoft.OData.Core' -MinimumVersion ([version]'7.22.0.0'))) { - throw [System.IO.FileNotFoundException]::new("Could not load file or assembly 'Microsoft.OData.Core, Version=7.22.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. Could not find or load a specific file. (0x80131621)") + param( + [Parameter()] [int]$ResultSize + ) + if ($global:DPSyntheticODataVersion -and [version]$global:DPSyntheticODataVersion -lt [version]'7.22.0.0') { + throw [System.IO.FileNotFoundException]::new("Could not load file or assembly 'Microsoft.OData.Core, Version=7.22.0.0, Culture=neutral, PublicKeyToken=31bf3856ad364e35'. The located assembly's manifest definition does not match the assembly reference. (0x80131040)") } - + $global:DPSyntheticODataVersion = '7.22.0.0' [PSCustomObject]@{ DisplayName = 'Synthetic mailbox' } } - Export-ModuleMember -Function Connect-ExchangeOnline, Get-EXOMailbox '@ | Set-Content -LiteralPath $ModuleFile -Encoding UTF8 } @@ -136,6 +123,26 @@ Describe 'Issue 174 Az.Storage and ExchangeOnlineManagement OData reproduction' $MailboxStep.Error.Message | Should -Match 'Microsoft.OData.Core' } + It 'fails the EXO-first order too: importing Az.Storage after EXO is loaded throws' { + $Result = Invoke-DLLPickleScenario -Name 'Issue174-EXOThenAzStorage-Synthetic' ` + -ModuleManifestPath $BuiltModuleManifestPath ` + -AdditionalModulePath $SyntheticModuleRoot ` + -OutputPath (Join-Path $ScenarioOutputRoot 'Issue174-EXOThenAzStorage-Synthetic.json') ` + -Step @( + @{ Name = 'Import ExchangeOnlineManagement'; Script = 'Import-Module ExchangeOnlineManagement -Force' } + @{ Name = 'Connect ExchangeOnlineManagement'; Script = 'Connect-ExchangeOnline -ManagedIdentity -Organization synthetic.example' } + @{ Name = 'Get EXO Mailbox'; Script = 'Get-EXOMailbox' } + @{ Name = 'Import Az.Storage'; Script = 'Import-Module Az.Storage -Force' } + ) + + $Result.Success | Should -BeFalse + $MailboxStep = $Result.Steps | Where-Object Name -EQ 'Get EXO Mailbox' + $MailboxStep.Success | Should -BeTrue + $AzStorageStep = $Result.Steps | Where-Object Name -EQ 'Import Az.Storage' + $AzStorageStep.Success | Should -BeFalse + $AzStorageStep.Error.Message | Should -Match 'Microsoft.OData.Core' + } + It 'runs the live Az.Storage and ExchangeOnlineManagement import probe when explicitly enabled' -Tag 'LiveRepro' -Skip:($env:DLLPICKLE_RUN_LIVE_REPRO -ne '1') { $Result = Invoke-DLLPickleScenario -Name 'Issue174-Live-AzStorageThenEXO' ` -ModuleManifestPath $BuiltModuleManifestPath ` diff --git a/tests/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 new file mode 100644 index 0000000..0114c31 --- /dev/null +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -0,0 +1,110 @@ +BeforeAll { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $ExportScript = Join-Path $RepoRoot 'build\Export-DLLPickleKnownConflicts.ps1' + $PolicyPath = Join-Path $RepoRoot 'build\dependency-policy.json' + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Test-DPModuleConflict.ps1') + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Get-DPKnownConflict.ps1') + . (Join-Path $RepoRoot 'src\DLLPickle\Private\Format-DPConflictWarning.ps1') + . (Join-Path $RepoRoot 'src\DLLPickle\Public\Test-DPLibraryConflict.ps1') + + $SampleConflict = [PSCustomObject]@{ + id = 'sample'; modules = @('Alpha', 'Beta'); assembly = 'Some.Assembly'; issue = '999' + reason = 'Alpha and Beta clash.'; workaround = 'Use separate sessions.' + } +} + +Describe 'Test-DPModuleConflict' -Tag 'Unit' { + It 'returns a conflict when every module in the pair is loaded' { + $Active = Test-DPModuleConflict -Conflict @($SampleConflict) -LoadedModule @('Alpha', 'Beta', 'Gamma') + @($Active).Count | Should -Be 1 + $Active[0].id | Should -Be 'sample' + } + + It 'returns nothing when only one module in the pair is loaded' { + $Active = Test-DPModuleConflict -Conflict @($SampleConflict) -LoadedModule @('Alpha', 'Gamma') + @($Active) | Should -BeNullOrEmpty + } + + It 'returns nothing for an empty conflict list' { + $Active = Test-DPModuleConflict -Conflict @() -LoadedModule @('Alpha', 'Beta') + @($Active) | Should -BeNullOrEmpty + } +} + +Describe 'Format-DPConflictWarning' -Tag 'Unit' { + It 'includes the modules, workaround, and issue link' { + $Message = Format-DPConflictWarning -Conflict $SampleConflict + $Message | Should -Match 'Alpha' + $Message | Should -Match 'Beta' + $Message | Should -Match 'separate sessions' + $Message | Should -Match 'issues/999' + } +} + +Describe 'Get-DPKnownConflict' -Tag 'Unit' { + It 'reads conflicts from an explicit path' { + $Path = Join-Path $TestDrive 'kc.json' + ConvertTo-Json -InputObject @($SampleConflict) -Depth 20 | Set-Content -LiteralPath $Path -Encoding utf8 + $Conflicts = Get-DPKnownConflict -Path $Path + @($Conflicts).Count | Should -Be 1 + $Conflicts[0].id | Should -Be 'sample' + } + + It 'returns an empty array when the file is missing' { + $Conflicts = Get-DPKnownConflict -Path (Join-Path $TestDrive 'nope.json') + @($Conflicts) | Should -BeNullOrEmpty + } + + It 'returns an empty array (no throw) when the file is malformed' { + $Path = Join-Path $TestDrive 'bad.json' + Set-Content -LiteralPath $Path -Value '{ not json' -Encoding utf8 + { Get-DPKnownConflict -Path $Path } | Should -Not -Throw + @(Get-DPKnownConflict -Path $Path) | Should -BeNullOrEmpty + } +} + +Describe 'Export-DLLPickleKnownConflicts' -Tag 'Unit' { + It 'writes the policy knownConflicts array to the output file verbatim' { + $Out = Join-Path $TestDrive 'KnownConflicts.json' + & $ExportScript -PolicyPath $PolicyPath -OutputPath $Out + Test-Path -LiteralPath $Out | Should -BeTrue + $Written = Get-Content -LiteralPath $Out -Raw | ConvertFrom-Json + $Policy = Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json + @($Written).Count | Should -Be @($Policy.knownConflicts).Count + ($Written | Where-Object id -EQ '174-odata-azstorage-exo') | Should -Not -BeNullOrEmpty + } +} + +Describe 'Test-DPLibraryConflict' -Tag 'Unit' { + BeforeAll { + Import-Module Microsoft.PowerShell.Management -ErrorAction SilentlyContinue + Import-Module Microsoft.PowerShell.Utility -ErrorAction SilentlyContinue + $LoadedPairPath = Join-Path $TestDrive 'loaded-pair.json' + ConvertTo-Json -Depth 20 -InputObject @( + [PSCustomObject]@{ id = 'loaded'; modules = @('Microsoft.PowerShell.Management', 'Microsoft.PowerShell.Utility'); assembly = 'x'; issue = '174'; reason = 'r'; workaround = 'w' } + ) | Set-Content -LiteralPath $LoadedPairPath -Encoding utf8 + $UnloadedPairPath = Join-Path $TestDrive 'unloaded-pair.json' + ConvertTo-Json -Depth 20 -InputObject @( + [PSCustomObject]@{ id = 'unloaded'; modules = @('No.Such.ModuleA', 'No.Such.ModuleB'); assembly = 'x'; issue = '174'; reason = 'r'; workaround = 'w' } + ) | Set-Content -LiteralPath $UnloadedPairPath -Encoding utf8 + } + + It 'warns and returns the conflict when both modules are loaded' { + $Active = Test-DPLibraryConflict -KnownConflictsPath $LoadedPairPath -WarningAction SilentlyContinue + @($Active).Count | Should -Be 1 + $Active[0].id | Should -Be 'loaded' + } + + It 'emits a Write-Warning when a conflict is active' { + $Warnings = $null + Test-DPLibraryConflict -KnownConflictsPath $LoadedPairPath -WarningVariable Warnings -WarningAction SilentlyContinue | Out-Null + @($Warnings).Count | Should -BeGreaterThan 0 + } + + It 'is silent and returns nothing when no conflict pair is fully loaded' { + $Warnings = $null + $Active = Test-DPLibraryConflict -KnownConflictsPath $UnloadedPairPath -WarningVariable Warnings -WarningAction SilentlyContinue + @($Active) | Should -BeNullOrEmpty + @($Warnings) | Should -BeNullOrEmpty + } +}