diff --git a/docs/Architecture.md b/docs/Architecture.md index da47105..ece661a 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -61,7 +61,7 @@ Every tracked assembly is classified into exactly one of: | Loader | `src/DLLPickle/Public/Import-DPLibrary.ps1` | Loads the bundled `bin/net8.0` DLLs into the default ALC with dependency-ordered, retrying loads. | | Build project | `src/DLLPickle.Build/DLLPickle.csproj` | **Realizes** the preload set — an assembly is preloaded **iff** it is a direct/transitive package reference here. `packages.lock.json` pins resolved versions. | | Dependency policy | `build/dependency-policy.json` | **Decision source of truth** — per-assembly classification + evidence, monitored modules, target scenario, drift baseline. | -| Analysis tools | `tools/New-DLLPickleConflictMatrix.ps1`, `Compare-DLLPickleConflictMatrix.ps1`, `Get-DLLPickleRuntimeAssemblySnapshot.ps1`, `Get-DLLPickleUpstreamInventory.ps1`, `Update-DLLPickleDependencyPins.ps1` | Inventory upstream modules, build the conflict matrix, probe runtime ALC ownership, detect drift, and apply policy pins. | +| Analysis tools | `tools/Get-DLLPickleLoadedTrackedAssembly.ps1`, `New-DLLPickleConflictMatrix.ps1`, `Compare-DLLPickleConflictMatrix.ps1`, `Get-DLLPickleRuntimeAssemblySnapshot.ps1`, `Get-DLLPickleUpstreamInventory.ps1`, `Update-DLLPickleDependencyPins.ps1` | Inventory upstream modules, build the conflict matrix, probe runtime ALC ownership (filter sourced from `trackedAssemblies`), detect drift, and apply policy pins. | | Build script | `build/DLLPickle.Build.ps1` | Invoke-Build tasks: Analyze, AnalyzeTests, AnalyzeTools, Test, RestoreDependencies, PrepareModuleOutput, IntegrationTest. | | CI | `.github/workflows/` | Build/test matrix, Upstream-Compatibility (inventory + drift), Dependabot auto-approve, tag-driven Release-and-Publish. | | Build output | `module/DLLPickle/` | **Generated** (gitignored). Rebuilt by `PrepareModuleOutput`; never hand-edited. | diff --git a/docs/superpowers/plans/2026-06-01-issue174-odata-alc-probe.md b/docs/superpowers/plans/2026-06-01-issue174-odata-alc-probe.md new file mode 100644 index 0000000..d9d891f --- /dev/null +++ b/docs/superpowers/plans/2026-06-01-issue174-odata-alc-probe.md @@ -0,0 +1,370 @@ +# Issue #174 OData ALC Probe — Phase 1 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:** Build the runtime ALC-ownership probe tooling that captures `Microsoft.OData.*`/`Microsoft.Spatial` (and the rest of the tracked stack) so the maintainer can gather the evidence needed to adjudicate issue #174. + +**Architecture:** A new in-session dump script (`tools/Get-DLLPickleLoadedTrackedAssembly.ps1`) sources its assembly filter from `build/dependency-policy.json` → `trackedAssemblies` and resolves each loaded assembly's ALC. The existing spawn-a-clean-child probe (`tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1`) is refactored to reuse that helper inside its child process (DRY) via a new `-PolicyPath` parameter, replacing its hardcoded regex. A unit test file covers both. The live-probe runbook (in the design spec) then calls the helper. + +**Tech Stack:** PowerShell 7.4+, `System.Runtime.Loader.AssemblyLoadContext`, Pester 5, Invoke-Build (`Analyze`, `Test`, `AnalyzeTools`). + +**Spec:** `docs/superpowers/specs/2026-06-01-issue174-odata-alc-probe-design.md` (Phase 1 = Components A, B, the runbook, tests; Phase 2 = the #174 resolution, evidence-gated, OUT OF SCOPE here). + +--- + +## File Structure + +- **Create** `tools/Get-DLLPickleLoadedTrackedAssembly.ps1` — Component A: in-session dump of loaded tracked assemblies + ALC. One responsibility: "given the policy, what tracked assemblies are loaded right now and in which ALC." +- **Modify** `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` — Component B: spawn a clean child, import modules, run the probe command, then call Component A in the child to produce the snapshot. Adds `-PolicyPath`; removes the hardcoded regex. +- **Create** `tests/Unit/RuntimeAssemblyProbe.Tests.ps1` — unit tests for A (in-process) and B (end-to-end via a spawned child against an always-loaded assembly). +- **Modify** `docs/Architecture.md` — §4 component map: add Component A and note the snapshot tool now sources its filter from `trackedAssemblies`. + +Each task ends green (analyzer + tests) and is committed independently. + +--- + +## Task 1: Component A — `tools/Get-DLLPickleLoadedTrackedAssembly.ps1` + +**Files:** +- Create: `tools/Get-DLLPickleLoadedTrackedAssembly.ps1` +- Test: `tests/Unit/RuntimeAssemblyProbe.Tests.ps1` + +- [ ] **Step 1: Write the failing tests** + +Create `tests/Unit/RuntimeAssemblyProbe.Tests.ps1`: + +```powershell +BeforeAll { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $LoadedScript = Join-Path $RepoRoot 'tools\Get-DLLPickleLoadedTrackedAssembly.ps1' + + # Named Get-* (not New-*): the AnalyzeTests task only excludes PSUseDeclaredVarsMoreThanAssignments, + # so a New-*/Set-* helper would trip PSUseShouldProcessForStateChangingFunctions and fail the gate. + function Get-TempPolicyPath { + param([string[]]$TrackedAssemblies) + $Path = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString('n') + '.json') + [PSCustomObject]@{ trackedAssemblies = $TrackedAssemblies } | + ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding utf8 + $Path + } +} + +Describe 'Get-DLLPickleLoadedTrackedAssembly' -Tag 'Unit' { + It 'returns a loaded assembly that is in trackedAssemblies, with version + ALC' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy + $Row = $Result | Where-Object Name -EQ 'System.Management.Automation' + $Row | Should -Not -BeNullOrEmpty + $Row.Alc | Should -Not -BeNullOrEmpty + $Row.Version | Should -Not -BeNullOrEmpty + } + + It 'excludes loaded assemblies that are not in trackedAssemblies' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy + ($Result | Where-Object Name -EQ 'System.Private.CoreLib') | Should -BeNullOrEmpty + } + + It 'returns nothing when -NameLike matches no tracked+loaded assembly' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy -NameLike 'Microsoft.OData*' + @($Result) | Should -BeNullOrEmpty + } + + It 'returns the row when -NameLike matches a tracked+loaded assembly' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy -NameLike 'System.Management.*' + ($Result | Where-Object Name -EQ 'System.Management.Automation') | Should -Not -BeNullOrEmpty + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/RuntimeAssemblyProbe.Tests.ps1 -Output Detailed"` +Expected: FAIL — the script does not exist yet (`& $LoadedScript` errors with "The term '...Get-DLLPickleLoadedTrackedAssembly.ps1' is not recognized" / path not found). + +- [ ] **Step 3: Create the script** + +Create `tools/Get-DLLPickleLoadedTrackedAssembly.ps1`: + +```powershell +<# +.SYNOPSIS + Reports the assemblies loaded in the CURRENT session whose simple name is tracked by the + dependency policy, with version and AssemblyLoadContext (ALC). +.DESCRIPTION + Enumerates [System.AppDomain]::CurrentDomain.GetAssemblies(), keeps those whose GetName().Name is + in the policy's trackedAssemblies (optionally further filtered by -NameLike wildcards), and + resolves each one's ALC name (or 'Default'). A private ALC name signals the owning module + self-manages that assembly. Shared by the #174 live-probe runbook and by the child process of + Get-DLLPickleRuntimeAssemblySnapshot.ps1, so the merge-gate filter logic lives in one place. + + Requires a session where direct .NET API access is permitted (Full Language Mode, or Constrained + Language AUDIT mode); these reflection calls are blocked under enforced Constrained Language Mode. +.PARAMETER PolicyPath + Path to dependency-policy.json. Defaults to build/dependency-policy.json relative to the repo root + (the parent of this script's tools/ folder). +.PARAMETER NameLike + Optional wildcard patterns; when supplied, an assembly must ALSO match one of them to be returned. +.OUTPUTS + PSCustomObject[] with Name, Version, Alc, Path. Sorted by Name. +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$PolicyPath, + + [Parameter()] + [string[]]$NameLike +) + +$ErrorActionPreference = 'Stop' + +if (-not $PolicyPath) { + $PolicyPath = Join-Path -Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -ChildPath 'build/dependency-policy.json' +} + +$TrackedNames = @((Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json).trackedAssemblies) + +[System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $TrackedNames -contains $_.GetName().Name } | + Where-Object { + if (-not $NameLike) { return $true } + $AssemblyName = $_.GetName().Name + foreach ($Pattern in $NameLike) { + if ($AssemblyName -like $Pattern) { return $true } + } + return $false + } | + ForEach-Object { + $Alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) + [PSCustomObject]@{ + Name = $_.GetName().Name + Version = $_.GetName().Version.ToString() + Alc = if ($Alc -and $Alc.Name) { $Alc.Name } else { 'Default' } + Path = $_.Location + } + } | + Sort-Object Name +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/RuntimeAssemblyProbe.Tests.ps1 -Output Detailed"` +Expected: PASS — 4/4. + +- [ ] **Step 5: Run the analyzer on tools/ + tests/ to verify clean** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze"` +Expected: PASS — `Analyze`, `AnalyzeTests`, `AnalyzeTools` all complete with no findings (the build fails on any finding). The script uses the approved `Get-` verb and no `Write-Host`, so no `PSUseShouldProcessForStateChangingFunctions`/`PSAvoidUsingWriteHost` findings. + +- [ ] **Step 6: Commit** + +```bash +git add tools/Get-DLLPickleLoadedTrackedAssembly.ps1 tests/Unit/RuntimeAssemblyProbe.Tests.ps1 +git commit -m "feat(tools): add Get-DLLPickleLoadedTrackedAssembly (policy-driven in-session ALC dump)" +``` + +--- + +## Task 2: Component B — refactor `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` + +**Files:** +- Modify: `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` +- Test: `tests/Unit/RuntimeAssemblyProbe.Tests.ps1` (add a Describe block) + +- [ ] **Step 1: Write the failing test** + +Append this `Describe` block to `tests/Unit/RuntimeAssemblyProbe.Tests.ps1` (the `BeforeAll` from Task 1 already defines `$RepoRoot` and `Get-TempPolicyPath`; add `$SnapshotScript` there too — see Step 1a): + +Step 1a — extend the existing `BeforeAll` (after the `$LoadedScript = ...` line) with: + +```powershell + $SnapshotScript = Join-Path $RepoRoot 'tools\Get-DLLPickleRuntimeAssemblySnapshot.ps1' +``` + +Step 1b — append this block at the end of the file: + +```powershell +Describe 'Get-DLLPickleRuntimeAssemblySnapshot' -Tag 'Unit' { + It 'sources its filter from -PolicyPath and returns tracked assemblies loaded in the child session' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + # Microsoft.PowerShell.Management is always importable; the child always has SMA loaded. + $Result = & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy + ($Result | Where-Object Name -EQ 'System.Management.Automation') | Should -Not -BeNullOrEmpty + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/RuntimeAssemblyProbe.Tests.ps1 -Output Detailed"` +Expected: FAIL — the current script has no `-PolicyPath` parameter, so binding errors ("A parameter cannot be found that matches parameter name 'PolicyPath'"). + +- [ ] **Step 3: Refactor the snapshot script** + +Replace the entire contents of `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` with: + +```powershell +<# +.SYNOPSIS + Snapshots which tracked assemblies a module loads, and into which AssemblyLoadContext. +.DESCRIPTION + Spawns a fresh pwsh process, optionally preloads DLLPickle, imports the named module(s) in order, + optionally runs a probe command, then reports each loaded assembly whose name is in the dependency + policy's trackedAssemblies, with its version, path, and ALC name. The set of tracked names (and the + ALC capture) is sourced from build/dependency-policy.json via Get-DLLPickleLoadedTrackedAssembly.ps1, + so this tool and the live-probe runbook share one filter. A private ALC (name other than 'Default') + indicates the module self-manages that assembly — a strong signal that DLLPickle must NOT preload it. +.PARAMETER ModuleName + One or more modules to import, in order. +.PARAMETER PreloadDllPickleManifest + Optional path to a DLLPickle manifest; when supplied, Import-DPLibrary runs before the imports. +.PARAMETER ProbeCommand + Optional command string run after imports (e.g. 'Get-AzContext') to force lazy ALC init. +.PARAMETER PolicyPath + Path to dependency-policy.json. Defaults to build/dependency-policy.json relative to the repo root. +.OUTPUTS + PSCustomObject[] one row per loaded tracked assembly: Name, Version, Alc, Path. +#> +[CmdletBinding()] +param( + [Parameter(Mandatory)] + [string[]]$ModuleName, + + [Parameter()] + [string]$PreloadDllPickleManifest, + + [Parameter()] + [string]$ProbeCommand, + + [Parameter()] + [string]$PolicyPath +) + +$ErrorActionPreference = 'Stop' + +$HelperScript = Join-Path -Path $PSScriptRoot -ChildPath 'Get-DLLPickleLoadedTrackedAssembly.ps1' +if (-not $PolicyPath) { + $PolicyPath = Join-Path -Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -ChildPath 'build/dependency-policy.json' +} + +$ChildScript = @' +param($ModuleNames, $PreloadManifest, $ProbeCommand, $HelperScript, $PolicyPath) +$ModuleNames = $ModuleNames -split ',' +$ErrorActionPreference = 'Continue' +if ($PreloadManifest) { + Import-Module $PreloadManifest -Force + Import-DPLibrary -SuppressLogo | Out-Null +} +foreach ($Name in $ModuleNames) { Import-Module $Name -Force -ErrorAction Continue } +if ($ProbeCommand) { try { Invoke-Expression $ProbeCommand | Out-Null } catch { } } +& $HelperScript -PolicyPath $PolicyPath | ConvertTo-Json -Depth 5 +'@ + +$TempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("dpp-snap-{0}.ps1" -f ([System.Guid]::NewGuid().ToString('n'))) +Set-Content -LiteralPath $TempScript -Value $ChildScript -Encoding utf8NoBOM +try { + $ChildArguments = @( + '-NoProfile', '-NonInteractive', '-File', $TempScript, + '-ModuleNames', ($ModuleName -join ','), + '-HelperScript', $HelperScript, + '-PolicyPath', $PolicyPath + ) + if ($PreloadDllPickleManifest) { $ChildArguments += @('-PreloadManifest', $PreloadDllPickleManifest) } + if ($ProbeCommand) { $ChildArguments += @('-ProbeCommand', $ProbeCommand) } + $Raw = & pwsh @ChildArguments + $Json = ($Raw | Out-String).Trim() + if ([string]::IsNullOrWhiteSpace($Json)) { return @() } + @($Json | ConvertFrom-Json) +} finally { + Remove-Item -LiteralPath $TempScript -Force -ErrorAction SilentlyContinue +} +``` + +Key changes from the previous version: added the `-PolicyPath` parameter; the child now calls `Get-DLLPickleLoadedTrackedAssembly.ps1` (passed as `-HelperScript`) instead of the inline `$Pattern` regex + AppDomain enumeration, so the filter is the policy's `trackedAssemblies` (now including OData/Spatial). Behavior trade-off (per spec): the old regex also captured incidental BCL assemblies (`System.Text.Json`, `System.Memory.Data`, `Microsoft.Bcl.AsyncInterfaces`) that are not tracked; those are dropped (runtime-provided, not conflict sources). + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `pwsh -NoProfile -Command "Invoke-Pester -Path ./tests/Unit/RuntimeAssemblyProbe.Tests.ps1 -Output Detailed"` +Expected: PASS — 5/5 (the 4 from Task 1 + the new snapshot test). Note: the snapshot test spawns a child `pwsh` (~1-3s). + +- [ ] **Step 5: Run the analyzer to verify clean** + +Run: `pwsh -NoProfile -Command "Invoke-Build -File ./build/DLLPickle.Build.ps1 -Task Analyze"` +Expected: PASS — no findings. + +- [ ] **Step 6: Commit** + +```bash +git add tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 tests/Unit/RuntimeAssemblyProbe.Tests.ps1 +git commit -m "refactor(tools): source runtime-snapshot filter from trackedAssemblies (captures OData)" +``` + +--- + +## Task 3: Update the architecture component map + +**Files:** +- Modify: `docs/Architecture.md` (§4 component map row for analysis tools) + +- [ ] **Step 1: Update the §4 "Analysis tools" row** + +In `docs/Architecture.md`, find the §4 table row that begins with `| Analysis tools |` and listing the `tools/` scripts. Add `Get-DLLPickleLoadedTrackedAssembly.ps1` to that list and note the policy-sourced filter. Replace the row's path cell so it reads (keep the table formatting): + +```markdown +| Analysis tools | `tools/Get-DLLPickleLoadedTrackedAssembly.ps1`, `New-DLLPickleConflictMatrix.ps1`, `Compare-DLLPickleConflictMatrix.ps1`, `Get-DLLPickleRuntimeAssemblySnapshot.ps1`, `Get-DLLPickleUpstreamInventory.ps1`, `Update-DLLPickleDependencyPins.ps1` | Inventory upstream modules, build the conflict matrix, probe runtime ALC ownership (filter sourced from `trackedAssemblies`), detect drift, and apply policy pins. | +``` + +- [ ] **Step 2: Verify the table still renders (no broken pipes)** + +Run: `pwsh -NoProfile -Command "Get-Content ./docs/Architecture.md | Select-String 'Get-DLLPickleLoadedTrackedAssembly'"` +Expected: one line — the §4 row containing the new script. + +- [ ] **Step 3: Commit** + +```bash +git add docs/Architecture.md +git commit -m "docs: list Get-DLLPickleLoadedTrackedAssembly in the architecture component map" +``` + +--- + +## Task 4: Validate the full gate and hand off the live-probe runbook + +**Files:** none (verification + handoff) + +- [ ] **Step 1: Run the full PR-smoke 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 (the existing suite + the 5 new probe tests). "Build succeeded". + +- [ ] **Step 2: Smoke-test the helper locally (no auth, no spawn)** + +Run: `pwsh -NoProfile -Command "Import-Module ./module/DLLPickle/DLLPickle.psd1 -Force; & ./tools/Get-DLLPickleLoadedTrackedAssembly.ps1 | Format-Table -AutoSize"` +Expected: a table of tracked assemblies loaded by importing the DLLPickle module (e.g. the MSAL / IdentityModel preload set), each with an `Alc` (likely `Default`). Confirms the policy-driven filter + ALC resolution work end-to-end. + +- [ ] **Step 3: Hand off the live-probe runbook to the maintainer** + +The probe is now usable. Surface the §5 runbook from the spec to the maintainer, with the `probe` helper bound to the built script. The maintainer runs each scenario in a fresh `pwsh` (Full Language Mode or CLM audit), against their dev tenant, and pastes back the per-step `probe` output (OData version(s) + ALC + cmdlet success/error). That evidence feeds the Phase 2 (#174) adjudication — OUT OF SCOPE for this plan. + +One-line `probe` binding to paste once per session: + +```powershell +$RepoRoot = (git rev-parse --show-toplevel) # run from inside your DLLPickle clone +function probe { & "$RepoRoot/tools/Get-DLLPickleLoadedTrackedAssembly.ps1" -NameLike 'Microsoft.OData*','Microsoft.Spatial' | Format-Table -AutoSize } +``` + +- [ ] **Step 4: Final commit (if any doc/runbook tweaks were made); otherwise none** + +No code change in this task. If the runbook handoff prompted a spec tweak, commit it; otherwise nothing to commit. + +--- + +## Notes for the implementer + +- **Pester version:** the repo pins Pester 5.2.2–5.99.99. `Invoke-Pester -Path ` works with Pester 5; if `Invoke-Pester` is missing, run `Import-Module Pester -MinimumVersion 5.2.2` first (the build bootstrap installs it). +- **Analyzer gate:** `AnalyzeTests` excludes only `PSUseDeclaredVarsMoreThanAssignments`; `AnalyzeTools` excludes nothing. Do **not** name test-helper *functions* with state-changing verbs (`New-*`, `Set-*`, `Remove-*`): they trip `PSUseShouldProcessForStateChangingFunctions` and fail the gate. That is why the temp-policy helper is `Get-TempPolicyPath` (returns a path; file creation is incidental), matching the existing `Get-TestInventory`/`Get-DriftRow` test helpers in this repo. +- **CLM:** the probe uses `[AppDomain]`/`[AssemblyLoadContext]` reflection, blocked under enforced Constrained Language Mode. The maintainer's session was CLM **audit** (allowed). CI runs Full Language Mode. +- **No release impact:** `tools/`, `tests/`, and `docs/` are outside the Release-and-Publish bundle paths, so merging this triggers no PSGallery publish. diff --git a/docs/superpowers/specs/2026-06-01-issue174-odata-alc-probe-design.md b/docs/superpowers/specs/2026-06-01-issue174-odata-alc-probe-design.md new file mode 100644 index 0000000..f14ca94 --- /dev/null +++ b/docs/superpowers/specs/2026-06-01-issue174-odata-alc-probe-design.md @@ -0,0 +1,101 @@ +# Issue #174 OData ALC Probe & Adjudication — Design + +**Goal:** Gather runtime AssemblyLoadContext (ALC) evidence for the `Microsoft.OData.*` stack across Az.Storage / ExchangeOnlineManagement (and Teams), then adjudicate issue #174 with a workaround-first, preload-last decision framework. + +**Architecture:** Extend the existing runtime-probe tooling to source its assembly filter from `build/dependency-policy.json` (so it captures OData, which is already a tracked assembly), add a small in-session dump helper, provide a self-contained live-probe runbook the maintainer runs against a dev tenant, and define a decision tree that prefers zero-cost workarounds over bundling OData. + +**Tech stack:** PowerShell 7.4+, `System.Runtime.Loader.AssemblyLoadContext`, the existing `tools/` probe scripts, Pester for the unit test. + +--- + +## 1. Problem & context + +**#174 (OPEN):** With `Az.Storage` imported first, its `Microsoft.OData.Core` (e.g. 7.6.4) loads into the **default ALC**. When `ExchangeOnlineManagement`'s `Get-EXO*` cmdlets then lazily require a **higher** `Microsoft.OData.Core` (e.g. 7.22.0), the load fails: `Could not load file or assembly 'Microsoft.OData.Core, Version=7.22.0.0 ...'`. DLLPickle currently classifies the OData stack as `block` (report-only) and does **not** preload it, because preloading one version breaks the module that needs the other. The repro tests (`tests/Integration/DLLPickle.Issue174.OData.Tests.ps1`) **characterize** this; they do not fix it. + +**Probe gap:** `tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1` already spawns a clean child pwsh, imports modules in order, runs an optional `-ProbeCommand`, and reports each loaded identity-stack assembly with its ALC. But its hardcoded assembly-name regex omits `Microsoft.OData.*` / `Microsoft.Spatial`, so it cannot currently show OData ALC ownership. + +**Key unknowns the evidence must resolve:** +- Does either module load OData into a **private** ALC (self-isolated → no real default-ALC conflict), or the **default** ALC (shared → genuine conflict)? +- Does **import order** change the outcome (does loading the higher OData version first satisfy both)? +- Does Az.Storage tolerate the **higher** OData version EXO needs? + +## 2. Scope + +- **Phase 1 (this spec / implementable now):** the probe extension (Components A–B), the live-probe runbook (Component C), and the adjudication framework (Component D) recorded in the repo. +- **Phase 2 (evidence-gated, separate follow-up):** the actual #174 resolution — chosen by applying Component D to the maintainer-provided probe output. The resolution (documented workaround, a built-in assist, or — last resort — an OData preload) is **not** decided in this spec. + +**Out of scope:** Stage 2b secretless auth automation (deferred; the live probe stays maintainer-run for now). + +> **Placement (A & B):** both live in **`tools/`** as maintainer/analysis scripts — the same family as `New-DLLPickleConflictMatrix`, `Compare-DLLPickleConflictMatrix`, `Get-DLLPickleUpstreamInventory`, and `Update-DLLPickleDependencyPins` (`docs/Architecture.md` §4), kept analyzer-clean by the `AnalyzeTools` build task. They are **not** module code (`src/DLLPickle/`) — they run at adjudication time, not at `Import-DPLibrary` time, so shipping them to the Gallery would bloat the module and turn dev tooling into public API. They are **not** `.github/ci-scripts/` (CI glue: bootstrap, version bump, publish); like the other `tools/` scripts they may be *invoked* by CI but live in `tools/`. (If Phase 2's resolution is a built-in assist, that would be **module** code — a Private function callable from `Import-DPLibrary` — which is a separate decision from these probe tools.) + +## 3. Component A — `tools/Get-DLLPickleLoadedTrackedAssembly.ps1` (new, in-session dump) + +A small helper that reports, **for the current session**, every loaded assembly whose name is in the policy's `trackedAssemblies`, with version + ALC. This is the shared core used both by the live runbook (interactive, where `Connect-ExchangeOnline` auth cannot run inside a spawned child) and by the snapshot tool's child process. + +**Interface:** +``` +Get-DLLPickleLoadedTrackedAssembly.ps1 + [-PolicyPath ] # default: /build/dependency-policy.json + [-NameLike ] # optional post-filter, e.g. 'Microsoft.OData*','Microsoft.Spatial' + -> PSCustomObject[]: Name, Version, Alc, Path +``` + +**Behavior:** read `trackedAssemblies` from the policy; enumerate `[AppDomain]::CurrentDomain.GetAssemblies()`; keep those whose `GetName().Name` is in the tracked set (and, if `-NameLike` given, also match one of those wildcards); resolve each assembly's ALC via `AssemblyLoadContext::GetLoadContext` (name, or `Default`). Returns objects (sortable/formattable by the caller). + +## 4. Component B — refactor `Get-DLLPickleRuntimeAssemblySnapshot.ps1` + +- Add `-PolicyPath` (default: `build/dependency-policy.json` resolved **relative to the repo root via `$PSScriptRoot`** — same as Component A — not the caller's working directory). The parent reads `trackedAssemblies` and passes the names to the child instead of the hardcoded regex. +- The child filters loaded assemblies by membership in that tracked-name set (replacing the `$Pattern` regex). This automatically includes `Microsoft.OData.Core/Edm/Spatial`. +- `-ProbeCommand`, `-PreloadDllPickleManifest`, `-ModuleName`, and the spawn-clean-child design are unchanged. +- Trade-off (accepted): the old regex also captured incidental BCL assemblies (`System.Text.Json`, `System.Memory.Data`, `Microsoft.Bcl.AsyncInterfaces`) that are not in `trackedAssemblies`; those are runtime-provided and not conflict sources, so dropping them is fine. If diagnostic breadth is ever needed, an optional `-AdditionalName ` can be added later (YAGNI for now). + +## 5. Component C — live-probe runbook (maintainer runs, pastes output) + +Each scenario runs in a **fresh `pwsh`** to avoid cross-contamination; `Connect-ExchangeOnline` uses the maintainer's dev tenant. The runbook **calls the Component A script** after each step rather than pasting a function — a multi-line function does not paste reliably into an interactive pwsh session (confirmed: the `Show-Loaded` paste produced a `ParserError`), whereas a single-line script call is robust. **Therefore Component A is built first** and the runbook depends on it: + +```powershell +$RepoRoot = (git rev-parse --show-toplevel) # run from inside your DLLPickle clone +function probe { & "$RepoRoot/tools/Get-DLLPickleLoadedTrackedAssembly.ps1" -NameLike 'Microsoft.OData*','Microsoft.Spatial' | Format-Table -AutoSize } +``` + +`probe` is a one-line alias the maintainer pastes once (single line = no paste-parse issues); call it after each step. Note: the probe reads `[AppDomain]::CurrentDomain.GetAssemblies()` and `AssemblyLoadContext` — under PowerShell **Constrained Language Mode** these .NET calls are restricted (the maintainer's session showed CLM **audit** mode, which permits them); the runbook assumes a session where these APIs are allowed. + +**Scenarios** (each = a fresh session; capture `probe` output after each step): + +1. **Az.Storage alone:** `Import-Module Az.Storage`; `probe` (after-import); run a non-network storage cmdlet that touches OData (e.g. `New-AzStorageContext -StorageAccountName x -Anonymous` or `Get-Command -Module Az.Storage | Out-Null`); `probe` (after-cmdlet). +2. **EXO alone:** `Import-Module ExchangeOnlineManagement`; `probe` (after-import); `Connect-ExchangeOnline`; `Get-EXOMailbox -ResultSize 1`; `probe` (after-getexo). +3. **Az.Storage → EXO (failing order):** import Az.Storage, `probe`; import EXO + `Connect-ExchangeOnline` + `Get-EXOMailbox -ResultSize 1` (record success/error); `probe` (after-getexo). +4. **EXO → Az.Storage (candidate workaround order):** import EXO + `Connect-ExchangeOnline` + `Get-EXOMailbox -ResultSize 1`; `probe`; then import Az.Storage + a storage cmdlet (record whether Az.Storage works); `probe` (after-azstorage). +5. **With DLLPickle preloading the candidate OData version** (only if scenarios 1–4 suggest a coherent version exists): `Import-Module $RepoRoot/module/DLLPickle/DLLPickle.psd1`; `Import-DPLibrary`; then repeat scenario 3's imports/cmdlets, capturing `probe` at each step. + +**Captured per scenario:** the `OData.*` version(s) loaded, each one's ALC (Default vs a private name), and whether each module's representative cmdlet succeeded. + +## 6. Component D — #174 adjudication framework (workaround-first, preload-last) + +Apply in order to the probe evidence: + +1. **Self-isolation** — if Az.Storage and/or EXO load OData into a **private** ALC and run their own versions side-by-side without conflict, there is no default-ALC clash. → Document #174 as a runtime non-issue (order-independent) and close it; no bundling. +2. **Import-order / no-bundle workaround** — if loading the **higher** OData version first (EXO before Az.Storage) makes both modules work: + - Ship **documented guidance** (import EXO, or run a `Get-EXO*` command, before importing Az.Storage). Zero module-size cost. + - Evaluate a lightweight **built-in assist**: `Import-DPLibrary` (or a new helper) force-loads the higher **already-installed** `Microsoft.OData.Core` from the consuming module's own files into the default ALC — **without bundling it in DLLPickle** (still zero bundle-size cost). Feasible only if the higher version is present on the machine and load-order-first resolves the conflict. +3. **Preload fix (last resort)** — only if no workaround is reliable/efficient: bundle a coherent `Microsoft.OData.Core/Edm/Spatial` version in DLLPickle. **Explicitly weigh** the added module size (~1–2 MB for the OData stack) and validate that the bundled version works for **both** Az.Storage and EXO before adopting. + +**Decision is recorded** in `build/dependency-policy.json` (classification + evidence) and `docs/Architecture.md` per the standard adjudication loop (§8 of the blueprint). The weighing of option 2 (≈0 cost) vs option 3 (size cost) is documented so the call is evidence-based. + +## 7. Testing + +- **Component A/B (unit):** a Pester test in `tests/Unit/` that runs `Get-DLLPickleLoadedTrackedAssembly.ps1` against a synthetic policy (a temp `dependency-policy.json` listing a known-loaded assembly such as `System.Management.Automation`) and asserts the helper returns it with a resolved ALC; and that `Get-DLLPickleRuntimeAssemblySnapshot.ps1` includes an OData name in the names it passes to the child (assert via a policy containing `Microsoft.OData.Core`). Analyzer-clean per `AnalyzeTools`. +- **Existing #174 repro tests** remain as characterization and as the regression guard once a resolution lands. +- **Component C** scenarios are manual/maintainer-run (auth tier); not in CI. +- **Component D** produces no code in Phase 1; its output is the recorded decision in Phase 2. + +## 8. Follow-ups + +- **Phase 2:** apply Component D to the maintainer's probe output and implement the chosen resolution (its own spec/plan if it's a preload or a built-in assist). +- **Stage 2b auth automation** would let scenarios 2–5 run in CI; deferred. + +## 9. Success criteria + +- The probe captures `Microsoft.OData.*` / `Microsoft.Spatial` ALC ownership + versions, driven by `trackedAssemblies` (stays in sync with policy). +- The maintainer can run the runbook and produce a clear evidence table (version + ALC + cmdlet success) for scenarios 1–5. +- The adjudication framework yields an unambiguous, recorded decision for #174 that favors a reliable zero-bundle workaround and only bundles OData if nothing else works. diff --git a/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 new file mode 100644 index 0000000..b3e3312 --- /dev/null +++ b/tests/Unit/RuntimeAssemblyProbe.Tests.ps1 @@ -0,0 +1,53 @@ +BeforeAll { + $RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..\..')).Path + $LoadedScript = Join-Path $RepoRoot 'tools\Get-DLLPickleLoadedTrackedAssembly.ps1' + $SnapshotScript = Join-Path $RepoRoot 'tools\Get-DLLPickleRuntimeAssemblySnapshot.ps1' + + # Named Get-* (not New-*): the AnalyzeTests task only excludes PSUseDeclaredVarsMoreThanAssignments, + # so a New-*/Set-* helper would trip PSUseShouldProcessForStateChangingFunctions and fail the gate. + function Get-TempPolicyPath { + param([string[]]$TrackedAssemblies) + $Path = Join-Path $TestDrive ([System.Guid]::NewGuid().ToString('n') + '.json') + [PSCustomObject]@{ trackedAssemblies = $TrackedAssemblies } | + ConvertTo-Json | Set-Content -LiteralPath $Path -Encoding utf8 + $Path + } +} + +Describe 'Get-DLLPickleLoadedTrackedAssembly' -Tag 'Unit' { + It 'returns a loaded assembly that is in trackedAssemblies, with version + ALC' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy + $Row = $Result | Where-Object Name -EQ 'System.Management.Automation' + $Row | Should -Not -BeNullOrEmpty + $Row.Alc | Should -Not -BeNullOrEmpty + $Row.Version | Should -Not -BeNullOrEmpty + } + + It 'excludes loaded assemblies that are not in trackedAssemblies' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy + ($Result | Where-Object Name -EQ 'System.Private.CoreLib') | Should -BeNullOrEmpty + } + + It 'returns nothing when -NameLike matches no tracked+loaded assembly' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy -NameLike 'Microsoft.OData*' + @($Result) | Should -BeNullOrEmpty + } + + It 'returns the row when -NameLike matches a tracked+loaded assembly' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + $Result = & $LoadedScript -PolicyPath $Policy -NameLike 'System.Management.*' + ($Result | Where-Object Name -EQ 'System.Management.Automation') | Should -Not -BeNullOrEmpty + } +} + +Describe 'Get-DLLPickleRuntimeAssemblySnapshot' -Tag 'Unit' { + It 'sources its filter from -PolicyPath and returns tracked assemblies loaded in the child session' { + $Policy = Get-TempPolicyPath -TrackedAssemblies @('System.Management.Automation') + # Microsoft.PowerShell.Management is always importable; the child always has SMA loaded. + $Result = & $SnapshotScript -ModuleName 'Microsoft.PowerShell.Management' -PolicyPath $Policy + ($Result | Where-Object Name -EQ 'System.Management.Automation') | Should -Not -BeNullOrEmpty + } +} diff --git a/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 new file mode 100644 index 0000000..68f0bc3 --- /dev/null +++ b/tools/Get-DLLPickleLoadedTrackedAssembly.ps1 @@ -0,0 +1,58 @@ +<# +.SYNOPSIS + Reports the assemblies loaded in the CURRENT session whose simple name is tracked by the + dependency policy, with version and AssemblyLoadContext (ALC). +.DESCRIPTION + Enumerates [System.AppDomain]::CurrentDomain.GetAssemblies(), keeps those whose GetName().Name is + in the policy's trackedAssemblies (optionally further filtered by -NameLike wildcards), and + resolves each one's ALC name (or 'Default'). A private ALC name signals the owning module + self-manages that assembly. Shared by the #174 live-probe runbook and by the child process of + Get-DLLPickleRuntimeAssemblySnapshot.ps1, so the merge-gate filter logic lives in one place. + + Requires a session where direct .NET API access is permitted (Full Language Mode, or Constrained + Language AUDIT mode); these reflection calls are blocked under enforced Constrained Language Mode. +.PARAMETER PolicyPath + Path to dependency-policy.json. Defaults to build/dependency-policy.json relative to the repo root + (the parent of this script's tools/ folder). +.PARAMETER NameLike + Optional wildcard patterns; when supplied, an assembly must ALSO match one of them to be returned. +.OUTPUTS + PSCustomObject[] with Name, Version, Alc, Path. Sorted by Name. +#> +[CmdletBinding()] +param( + [Parameter()] + [string]$PolicyPath, + + [Parameter()] + [string[]]$NameLike +) + +$ErrorActionPreference = 'Stop' + +if (-not $PolicyPath) { + $PolicyPath = Join-Path -Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -ChildPath 'build/dependency-policy.json' +} + +$TrackedNames = @((Get-Content -LiteralPath $PolicyPath -Raw | ConvertFrom-Json).trackedAssemblies) + +[System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $TrackedNames -contains $_.GetName().Name } | + Where-Object { + if (-not $NameLike) { return $true } + $AssemblyName = $_.GetName().Name + foreach ($Pattern in $NameLike) { + if ($AssemblyName -like $Pattern) { return $true } + } + return $false + } | + ForEach-Object { + $Alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) + [PSCustomObject]@{ + Name = $_.GetName().Name + Version = $_.GetName().Version.ToString() + Alc = if ($Alc -and $Alc.Name) { $Alc.Name } else { 'Default' } + Path = $_.Location + } + } | + Sort-Object Name diff --git a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 index a87323d..c4bdd6c 100644 --- a/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 +++ b/tools/Get-DLLPickleRuntimeAssemblySnapshot.ps1 @@ -1,20 +1,23 @@ <# .SYNOPSIS - Snapshots which identity-stack assemblies a module loads, and into which AssemblyLoadContext. + Snapshots which tracked assemblies a module loads, and into which AssemblyLoadContext. .DESCRIPTION - Spawns a fresh pwsh process, optionally preloads DLLPickle, imports the named module(s) in - order, optionally runs a probe command, and returns each loaded Azure.*/Microsoft.Identity*/ - Microsoft.IdentityModel*/System.ClientModel/System.Text.Json assembly with its version, path, - and ALC name. A private ALC (name other than 'Default') indicates the module self-manages that - assembly, which is a strong signal that DLLPickle must NOT preload it. + Spawns a fresh pwsh process, optionally preloads DLLPickle, imports the named module(s) in order, + optionally runs a probe command, then reports each loaded assembly whose name is in the dependency + policy's trackedAssemblies, with its version, path, and ALC name. The set of tracked names (and the + ALC capture) is sourced from build/dependency-policy.json via Get-DLLPickleLoadedTrackedAssembly.ps1, + so this tool and the live-probe runbook share one filter. A private ALC (name other than 'Default') + indicates the module self-manages that assembly - a strong signal that DLLPickle must NOT preload it. .PARAMETER ModuleName One or more modules to import, in order. .PARAMETER PreloadDllPickleManifest Optional path to a DLLPickle manifest; when supplied, Import-DPLibrary runs before the imports. .PARAMETER ProbeCommand Optional command string run after imports (e.g. 'Get-AzContext') to force lazy ALC init. +.PARAMETER PolicyPath + Path to dependency-policy.json. Defaults to build/dependency-policy.json relative to the repo root. .OUTPUTS - PSCustomObject[] one row per loaded assembly: Name, Version, Alc, Path. + PSCustomObject[] one row per loaded tracked assembly: Name, Version, Alc, Path. #> [CmdletBinding()] param( @@ -25,13 +28,21 @@ param( [string]$PreloadDllPickleManifest, [Parameter()] - [string]$ProbeCommand + [string]$ProbeCommand, + + [Parameter()] + [string]$PolicyPath ) $ErrorActionPreference = 'Stop' +$HelperScript = Join-Path -Path $PSScriptRoot -ChildPath 'Get-DLLPickleLoadedTrackedAssembly.ps1' +if (-not $PolicyPath) { + $PolicyPath = Join-Path -Path (Resolve-Path (Join-Path $PSScriptRoot '..')).Path -ChildPath 'build/dependency-policy.json' +} + $ChildScript = @' -param($ModuleNames, $PreloadManifest, $ProbeCommand) +param($ModuleNames, $PreloadManifest, $ProbeCommand, $HelperScript, $PolicyPath) $ModuleNames = $ModuleNames -split ',' $ErrorActionPreference = 'Continue' if ($PreloadManifest) { @@ -40,25 +51,18 @@ if ($PreloadManifest) { } foreach ($Name in $ModuleNames) { Import-Module $Name -Force -ErrorAction Continue } if ($ProbeCommand) { try { Invoke-Expression $ProbeCommand | Out-Null } catch { } } - -$Pattern = '^(Azure\.|Microsoft\.Identity|Microsoft\.IdentityModel|System\.ClientModel|System\.Text\.Json|System\.Memory\.Data|Microsoft\.Bcl\.AsyncInterfaces|Microsoft\.Extensions\.)' -[System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -match $Pattern } | - ForEach-Object { - $Alc = [System.Runtime.Loader.AssemblyLoadContext]::GetLoadContext($_) - [PSCustomObject]@{ - Name = $_.GetName().Name - Version = $_.GetName().Version.ToString() - Alc = if ($Alc -and $Alc.Name) { $Alc.Name } else { 'Default' } - Path = $_.Location - } - } | ConvertTo-Json -Depth 5 +& $HelperScript -PolicyPath $PolicyPath | ConvertTo-Json -Depth 5 '@ $TempScript = Join-Path ([System.IO.Path]::GetTempPath()) ("dpp-snap-{0}.ps1" -f ([System.Guid]::NewGuid().ToString('n'))) Set-Content -LiteralPath $TempScript -Value $ChildScript -Encoding utf8NoBOM try { - $ChildArguments = @('-NoProfile', '-NonInteractive', '-File', $TempScript, '-ModuleNames', ($ModuleName -join ',')) + $ChildArguments = @( + '-NoProfile', '-NonInteractive', '-File', $TempScript, + '-ModuleNames', ($ModuleName -join ','), + '-HelperScript', $HelperScript, + '-PolicyPath', $PolicyPath + ) if ($PreloadDllPickleManifest) { $ChildArguments += @('-PreloadManifest', $PreloadDllPickleManifest) } if ($ProbeCommand) { $ChildArguments += @('-ProbeCommand', $ProbeCommand) } $Raw = & pwsh @ChildArguments