From a09c8305d77b810da95c290da4c517e2bdcee032 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:34:03 -0400 Subject: [PATCH 01/12] docs(spec): #174 Phase 2 conflict detection & warning design Data-driven knownConflicts (in dependency-policy.json, build-shipped as a small KnownConflicts.json subset), a Private detector, a public Test-DPLibraryConflict, and an Import-DPLibrary one-shot armed AssemblyLoad handler that warns when the Az.Storage + ExchangeOnlineManagement OData pair becomes co-loaded. Plus the OData evidence cross-ref, a known-limitation doc, the both-orders-fail #174 test, and a findings comment (issue kept open). OData stays block (unfixable by preload, per the Phase 1 runtime evidence). Co-Authored-By: Claude Opus 4.8 --- ...-06-01-issue174-conflict-warning-design.md | 86 +++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md 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..32e9c48 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md @@ -0,0 +1,86 @@ +# 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 sessions or processes (e.g., 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" + } +} +``` + +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 armed conflict `id`s so a second `Import-DPLibrary` call does not double-arm; the handler body is wrapped in try/catch and never throws. + +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 **sync test** (shipped `KnownConflicts.json` subset equals `dependency-policy.json` `knownConflicts`). 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. From 1afa574aa6e326adaf7e3853337fd68786d8f709 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 21:52:42 -0400 Subject: [PATCH 02/12] docs(plan): #174 Phase 2 conflict-warning implementation plan 8-task TDD plan: knownConflicts data + build-shipped KnownConflicts.json, private reader/detector/formatter, public Test-DPLibraryConflict, Import-DPLibrary armed one-shot AssemblyLoad warning, known-limitation doc, both-orders #174 test (shared OData-slot synthetic), and the #174 findings comment (keep open). Co-Authored-By: Claude Opus 4.8 --- .../2026-06-01-issue174-conflict-warning.md | 853 ++++++++++++++++++ 1 file changed, 853 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-01-issue174-conflict-warning.md 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. From 5d8b0fe810779da16ac1291285e0127f5b95a1df Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:20:36 -0400 Subject: [PATCH 03/12] feat(policy): add knownConflicts (#174 Az.Storage+EXO OData) + OData evidence cross-ref --- build/dependency-policy.json | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/build/dependency-policy.json b/build/dependency-policy.json index dedb6af..124e834 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 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" + } + } + ], "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." + } } ] } From 78839600283a39cb60fda64a31e66edad7381922 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:22:40 -0400 Subject: [PATCH 04/12] feat(build): ship knownConflicts.json into the module output --- build/DLLPickle.Build.ps1 | 10 ++++++++ build/Export-DLLPickleKnownConflicts.ps1 | 31 ++++++++++++++++++++++++ tests/Unit/KnownConflicts.Tests.ps1 | 17 +++++++++++++ 3 files changed, 58 insertions(+) create mode 100644 build/Export-DLLPickleKnownConflicts.ps1 create mode 100644 tests/Unit/KnownConflicts.Tests.ps1 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/tests/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 new file mode 100644 index 0000000..800e2e3 --- /dev/null +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -0,0 +1,17 @@ +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 + } +} From 9bd1242cb5c7993c687bdc1c818b7b97b7af93db Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:53:30 -0400 Subject: [PATCH 05/12] feat(module): add knownConflicts reader, detector, and warning formatter (private) --- .../Private/Format-DPConflictWarning.ps1 | 26 +++++++++ src/DLLPickle/Private/Get-DPKnownConflict.ps1 | 36 ++++++++++++ .../Private/Test-DPModuleConflict.ps1 | 35 +++++++++++ tests/Unit/KnownConflicts.Tests.ps1 | 58 +++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 src/DLLPickle/Private/Format-DPConflictWarning.ps1 create mode 100644 src/DLLPickle/Private/Get-DPKnownConflict.ps1 create mode 100644 src/DLLPickle/Private/Test-DPModuleConflict.ps1 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/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/tests/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 index 800e2e3..c97d27e 100644 --- a/tests/Unit/KnownConflicts.Tests.ps1 +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -2,6 +2,64 @@ 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') + + $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' { From 06410d5893ad1e224240c76f55d27a34b3cef088 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Mon, 1 Jun 2026 22:56:24 -0400 Subject: [PATCH 06/12] feat(module): add public Test-DPLibraryConflict --- src/DLLPickle/DLLPickle.psd1 | 8 ++--- .../Public/Test-DPLibraryConflict.ps1 | 34 ++++++++++++++++++ tests/Unit/KnownConflicts.Tests.ps1 | 35 +++++++++++++++++++ 3 files changed, 73 insertions(+), 4 deletions(-) create mode 100644 src/DLLPickle/Public/Test-DPLibraryConflict.ps1 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/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/Unit/KnownConflicts.Tests.ps1 b/tests/Unit/KnownConflicts.Tests.ps1 index c97d27e..0114c31 100644 --- a/tests/Unit/KnownConflicts.Tests.ps1 +++ b/tests/Unit/KnownConflicts.Tests.ps1 @@ -5,6 +5,7 @@ BeforeAll { . (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' @@ -73,3 +74,37 @@ Describe 'Export-DLLPickleKnownConflicts' -Tag 'Unit' { ($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 + } +} From 1cfbd6e3110211848cd62753f176d0ffa82764c8 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:42:06 -0400 Subject: [PATCH 07/12] feat(module): warn on known module conflicts from Import-DPLibrary (immediate + armed handler) --- .../Private/Invoke-DPConflictCheck.ps1 | 84 +++++++++++++++++++ src/DLLPickle/Public/Import-DPLibrary.ps1 | 7 ++ 2 files changed, 91 insertions(+) create mode 100644 src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 diff --git a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 new file mode 100644 index 0000000..ca92378 --- /dev/null +++ b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 @@ -0,0 +1,84 @@ +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) + [void]$EventSender + 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 { + Write-Verbose "AssemblyLoad conflict-watch handler error (advisory, suppressed): $_" + } + }.GetNewClosure() + [System.AppDomain]::CurrentDomain.add_AssemblyLoad($State.Handler) + } + } catch { + Write-Verbose "Conflict check skipped due to error: $_" + } + } +} 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 } From f797544474f43b3798c33252e740fe4946662634 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:43:28 -0400 Subject: [PATCH 08/12] docs: document the Az.Storage + EXO OData known limitation (#174) --- docs/Deep-Dive.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/Deep-Dive.md b/docs/Deep-Dive.md index eeea525..a514fb9 100644 --- a/docs/Deep-Dive.md +++ b/docs/Deep-Dive.md @@ -162,6 +162,24 @@ 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 sessions or processes** — for example, +run `Get-EXO*` work in one `pwsh`/runspace/background job and `Az.Storage` work in another. + ## 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) From 50b8179c668d5846ec63ef81b12f34648578a253 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 07:47:39 -0400 Subject: [PATCH 09/12] test(#174): model both import orders with a shared OData slot (EXO-first fails on Az.Storage import) --- .../DLLPickle.Issue174.OData.Tests.ps1 | 83 ++++++++++--------- 1 file changed, 45 insertions(+), 38 deletions(-) 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 ` From 946b447dc34d5a0a20e9c699ba1b6d9d89058daf Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:32:30 -0400 Subject: [PATCH 10/12] fix: address Codex/Copilot review on #231 (conflict-warning robustness) - Warn only when the WHOLE pair is co-loaded: the AssemblyLoad handler now tracks per-module "seen" and warns/unregisters only once every watched module has loaded (was false-warning + consuming the one-shot when just one module loaded). - Watch ALL installed version base paths per not-yet-loaded module (not just the highest), so a version-pinned/by-path import is still detected. - Idempotent: a module-scoped $script:DPConflictHandled set warns/arms at most once per conflict per session (no stacked handlers / duplicate warnings on repeated Import-DPLibrary). - Perf: query Get-Module -ListAvailable only for the conflict's own module names. - Normalize paths (GetFullPath) before the prefix check; guard empty Location. - Workaround text: separate PROCESS (job / second pwsh), not a same-process runspace (process-wide ALC) - corrected in policy, Deep-Dive, and the spec. - Document the best-effort limits (failed-load raises no event; Get-Module-based) in the function help + spec; clarify the spec's extractor (TestDrive) sync test. Co-Authored-By: Claude Opus 4.8 --- build/dependency-policy.json | 2 +- docs/Deep-Dive.md | 6 +- ...-06-01-issue174-conflict-warning-design.md | 8 +- .../Private/Invoke-DPConflictCheck.ps1 | 82 +++++++++++++------ 4 files changed, 67 insertions(+), 31 deletions(-) diff --git a/build/dependency-policy.json b/build/dependency-policy.json index 124e834..9b9a69c 100644 --- a/build/dependency-policy.json +++ b/build/dependency-policy.json @@ -77,7 +77,7 @@ "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).", + "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", diff --git a/docs/Deep-Dive.md b/docs/Deep-Dive.md index a514fb9..76f3e44 100644 --- a/docs/Deep-Dive.md +++ b/docs/Deep-Dive.md @@ -177,8 +177,10 @@ This is an upstream incompatibility between the two modules; **DLLPickle cannot (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. +**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 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 index 32e9c48..2d5ccc0 100644 --- a/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md +++ b/docs/superpowers/specs/2026-06-01-issue174-conflict-warning-design.md @@ -27,7 +27,7 @@ New top-level array `knownConflicts`. Each entry: "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 (e.g., run one in a background job or a separate runspace/pwsh).", + "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", @@ -57,7 +57,9 @@ After the preload completes, for each known conflict (all wrapped so a detection - 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 armed conflict `id`s so a second `Import-DPLibrary` call does not double-arm; the handler body is wrapped in try/catch and never throws. +- **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). @@ -74,7 +76,7 @@ The warning is advisory and must never degrade the session: all detection/handle ## 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 **sync test** (shipped `KnownConflicts.json` subset equals `dependency-policy.json` `knownConflicts`). Helpers use approved verbs (`Get-`/`Test-`) to stay `AnalyzeTests`-clean. +- **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). diff --git a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 index ca92378..50fff72 100644 --- a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 +++ b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 @@ -1,13 +1,20 @@ function Invoke-DPConflictCheck { <# .SYNOPSIS - After preload, warns about (or arms a one-shot warning for) known incompatible module pairs. + 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 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). + 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. #> @@ -27,55 +34,80 @@ function Invoke-DPConflictCheck { $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) - $AvailableNames = @(Get-Module -ListAvailable | Select-Object -ExpandProperty Name -Unique) 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 $Modules) { - if ($AvailableNames -notcontains $Name) { $AllInstalled = $false; break } + foreach ($Name in $NotLoaded) { + $Bases = @( + Get-Module -ListAvailable -Name $Name | + ForEach-Object { if ($_.ModuleBase) { [System.IO.Path]::GetFullPath($_.ModuleBase) } } | + Select-Object -Unique + ) + if ($Bases.Count -eq 0) { $AllInstalled = $false; break } + $WatchedModule.Add([PSCustomObject]@{ Name = $Name; Bases = $Bases }) } - if (-not $AllInstalled) { continue } + if (-not $AllInstalled -or $WatchedModule.Count -eq 0) { 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 } + # 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) { - 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 + $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: $_" From 99ec7801e5bcce7b08b039885b6f63b07a79f0c4 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:44:15 -0400 Subject: [PATCH 11/12] docs(changelog): add 2.2.0 entry (conflict detection/warning + release/CI hardening) Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) 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 From bbb876772afeff215ddd2982fe67ff95d70c3561 Mon Sep 17 00:00:00 2001 From: Sam Erde <20478745+SamErde@users.noreply.github.com> Date: Tue, 2 Jun 2026 08:56:27 -0400 Subject: [PATCH 12/12] fix: directory-prefix match in conflict-watch handler (avoid sibling false positives) Normalize each watched module base to end with a directory separator before the AssemblyLoad handler's StartsWith check, so e.g. '...\Az.Storage\' does not match a sibling '...\Az.Storage.Custom\'. Co-Authored-By: Claude Opus 4.8 --- src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 index 50fff72..a3a01db 100644 --- a/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 +++ b/src/DLLPickle/Private/Invoke-DPConflictCheck.ps1 @@ -63,9 +63,20 @@ function Invoke-DPConflictCheck { $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) { [System.IO.Path]::GetFullPath($_.ModuleBase) } } | + 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 }