From ca8855284f5bc464d67135b2a29caa0129ed814e Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 25 Oct 2026 16:49:10 +0100 Subject: [PATCH 1/2] #2 fix: enhance installed-copy discovery for mixed flat and versioned module installations --- CHANGELOG.md | 6 +++ README.md | 7 ++++ RELEASE_NOTE.md | 3 ++ .../en-US/Get-PSModuleUpdate.md | 6 +++ .../Get-InstalledModuleIdentityList.ps1 | 4 +- .../Test-InstallationDirectoryCandidate.ps1 | 5 +++ .../Get-InstalledModuleIdentityList.Tests.ps1 | 37 +++++++++++++++++-- ...t-InstallationDirectoryCandidate.Tests.ps1 | 14 +++++++ 8 files changed, 77 insertions(+), 5 deletions(-) create mode 100644 src/private/identity/Test-InstallationDirectoryCandidate.ps1 create mode 100644 tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 diff --git a/CHANGELOG.md b/CHANGELOG.md index abbc28f..b37b53b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,12 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Fixed +- Fixed installed-copy discovery failing before repository lookup when a legacy + flat installation leaves resource directories beside numeric version folders. + Ordinary resource folders are excluded, while numeric folders with missing + manifests and manifest-bearing invalid layouts still fail validation. No + installation is migrated or removed. + ### Security ## [0.1.0] - 2026-10-24 diff --git a/README.md b/README.md index 1754b53..d1e3d6a 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,13 @@ not silently create a copy in another scope or fall back to CurrentUser when AllUsers is not writable. Run in an appropriately authorized shell when system permissions are necessary; PMU never requests elevation. +Installed-copy discovery ignores ordinary resource directories, including +`en-US` and `resources`, beside numeric version directories. A legacy flat +copy may coexist with a versioned installation without blocking discovery; +PMU does not migrate or select that flat copy as a versioned installation. +Numeric version directories and other directories containing the target +module's manifest remain subject to manifest and layout validation. + `IncludePrerelease` considers stable and prerelease releases together. Complete versions use semantic prerelease ordering: `preview.10` follows `preview.2`, and a stable release follows prereleases with the same numeric base. diff --git a/RELEASE_NOTE.md b/RELEASE_NOTE.md index 8291c3a..3642539 100644 --- a/RELEASE_NOTE.md +++ b/RELEASE_NOTE.md @@ -28,4 +28,7 @@ This file summarizes public cmdlet, CLI, configuration, and migration changes fo ### Fixed +- Update checks no longer fail merely because `en-US` or `resources` folders + coexist with a versioned module installation. Existing files are left intact. + ### Security diff --git a/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md b/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md index 5909520..b7dc698 100644 --- a/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md +++ b/docs/PSModuleUpdate/en-US/Get-PSModuleUpdate.md @@ -52,6 +52,12 @@ two default to null. IncludePrerelease includes stable releases as well. The explicitly selected scope must already contain the matching name and GUID; PMU never creates a shadow installation or changes scope. +Ordinary resource folders beside numeric version directories do not count as +installed copies. This allows a versioned installation to coexist with resource +folders left by a legacy flat installation, without migrating or selecting the +flat copy. Numeric directories and directories containing the target module's +manifest still undergo manifest and layout validation. + The manifest supplies installed prerelease identity. The `Active*` fields describe a matching module actually loaded in the session and are null when the target is unloaded. diff --git a/src/private/identity/Get-InstalledModuleIdentityList.ps1 b/src/private/identity/Get-InstalledModuleIdentityList.ps1 index 18f8d78..52568ca 100644 --- a/src/private/identity/Get-InstalledModuleIdentityList.ps1 +++ b/src/private/identity/Get-InstalledModuleIdentityList.ps1 @@ -3,12 +3,14 @@ function Get-InstalledModuleIdentityList { $root = Resolve-ModuleInstallationRoot $Scope $directory = Assert-SafePath (Join-Path $root $Name) if (-not (Test-Path -LiteralPath $directory)) { return } - foreach ($child in Get-ChildItem -LiteralPath $directory -Directory -Force) { + $candidates = Get-ChildItem -LiteralPath $directory -Directory -Force | Where-Object { Test-InstallationDirectoryCandidate $_ $Name } + foreach ($child in $candidates) { if ($child.FullName -ceq $ExcludedPath) { continue } $identity = Get-ManifestIdentity (Join-Path $child.FullName "$Name.psd1") if ($child.Name -cne $identity.Version.NumericVersion) { Write-PMUError 'PMU.UnsupportedInstallation' 'Only provider numeric-version directory layouts are supported.' } + [pscustomobject]@{ FullVersion = $identity.Version.FullVersion; NumericVersion = $identity.Version.NumericVersion; Prerelease = $identity.Version.Prerelease; Scope = $Scope; Root = $root; ManifestPath = $identity.ManifestPath; Guid = $identity.Guid } } } diff --git a/src/private/identity/Test-InstallationDirectoryCandidate.ps1 b/src/private/identity/Test-InstallationDirectoryCandidate.ps1 new file mode 100644 index 0000000..8597fd8 --- /dev/null +++ b/src/private/identity/Test-InstallationDirectoryCandidate.ps1 @@ -0,0 +1,5 @@ +function Test-InstallationDirectoryCandidate { + param([System.IO.DirectoryInfo]$Directory, [string]$Name) + $manifest = Join-Path $Directory.FullName "$Name.psd1" + $Directory.Name -match '^[0-9]+\.[0-9]+(?:\.[0-9]+){0,2}$' -or (Test-Path -LiteralPath $manifest) +} diff --git a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 index 19a3192..54d104b 100644 --- a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 +++ b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 @@ -1,16 +1,45 @@ BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } Describe 'Get-InstalledModuleIdentityList' -Tag 'PMU' { + BeforeEach { + $script:installationRoot = Join-Path $TestDrive ([guid]::NewGuid()) + $null = New-Item $script:installationRoot -ItemType Directory + Mock Resolve-ModuleInstallationRoot { $script:installationRoot } + } It 'reads the manifest prerelease, not the numeric directory name' { - $null = Initialize-PMUTestPackage $TestDrive '2.0.0-preview.10' - Mock Resolve-ModuleInstallationRoot { $TestDrive } + $null = Initialize-PMUTestPackage $script:installationRoot '2.0.0-preview.10' $identity = Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' $identity.FullVersion | Should -Be '2.0.0-preview.10' $identity.Scope | Should -Be 'AllUsers' } It 'rejects a directory whose name differs from the manifest numeric version' { - $path = Initialize-PMUTestPackage $TestDrive '2.0.0' + $path = Initialize-PMUTestPackage $script:installationRoot '2.0.0' Rename-Item -LiteralPath $path -NewName '9.9.9' - Mock Resolve-ModuleInstallationRoot { $TestDrive } { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | Should -Throw -ErrorId 'PMU.UnsupportedInstallation' } + + It 'discovers a version beside flat-installation resources without changing the layout' -Tag 'InstalledLayout' { + $path = Initialize-PMUTestPackage $script:installationRoot '0.1.0' + $moduleRoot = Split-Path -Parent $path + Copy-Item "$path/FixtureModule.psd1" $moduleRoot + $null = New-Item "$moduleRoot/en-US", "$moduleRoot/resources" -ItemType Directory + $identity = @(Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers') + $identity.Count | Should -Be 1 + $identity[0].FullVersion | Should -Be '0.1.0' + $identity[0].ManifestPath | Should -Be "$path/FixtureModule.psd1" + "$moduleRoot/en-US", "$moduleRoot/resources", "$moduleRoot/FixtureModule.psd1" | + ForEach-Object { $_ | Should -Exist } + } + + It 'still rejects a numeric version directory without a manifest' -Tag 'InstalledLayout' { + $null = New-Item "$script:installationRoot/FixtureModule/0.1.0" -ItemType Directory -Force + { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | + Should -Throw -ErrorId 'PMU.ModuleIdentityMismatch' + } + + It 'still rejects a nonnumeric directory containing a module manifest' -Tag 'InstalledLayout' { + $path = Initialize-PMUTestPackage $script:installationRoot '0.1.0' + Rename-Item -LiteralPath $path -NewName 'unexpected' + { Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers' } | + Should -Throw -ErrorId 'PMU.UnsupportedInstallation' + } } diff --git a/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 b/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 new file mode 100644 index 0000000..0a1aa51 --- /dev/null +++ b/tests/private/identity/Test-InstallationDirectoryCandidate.Tests.ps1 @@ -0,0 +1,14 @@ +BeforeAll { . "$PSScriptRoot/../../TestHelpers/Import-PMUTestSource.ps1" } +Describe 'Test-InstallationDirectoryCandidate' -Tag 'PMU', 'InstalledLayout' { + It 'classifies with manifest as candidate ' -ForEach @( + @{ name = 'en-US'; manifest = $false; expected = $false } + @{ name = 'resources'; manifest = $false; expected = $false } + @{ name = '0.1.0'; manifest = $false; expected = $true } + @{ name = 'unexpected'; manifest = $true; expected = $true } + ) { + $root = Join-Path $TestDrive ([guid]::NewGuid()) + $directory = New-Item "$root/$name" -ItemType Directory -Force + if ($manifest) { $null = New-Item "$directory/FixtureModule.psd1" -ItemType File } + Test-InstallationDirectoryCandidate $directory 'FixtureModule' | Should -Be $expected + } +} From 86c7ef8e26321b8b0dd1a1d6289c0160a7dd483b Mon Sep 17 00:00:00 2001 From: Stiwi Gabriel Courage Date: Sun, 25 Oct 2026 20:37:09 +0100 Subject: [PATCH 2/2] #2 fix: enhance CI parity and readiness guidelines across documentation and tests --- .github/agents/powershell-developer.agent.md | 1 + .github/agents/test-engineer.agent.md | 3 ++- .../repository-conventions.instructions.md | 19 +++++++++++++++++++ CONTRIBUTING.md | 5 +++++ .../Get-InstalledModuleIdentityList.Tests.ps1 | 2 +- 5 files changed, 28 insertions(+), 2 deletions(-) diff --git a/.github/agents/powershell-developer.agent.md b/.github/agents/powershell-developer.agent.md index de5b25f..1f9faa5 100644 --- a/.github/agents/powershell-developer.agent.md +++ b/.github/agents/powershell-developer.agent.md @@ -18,6 +18,7 @@ Implement PowerShell command and helper changes in the PSModuleUpdate style. - Keep one externally called function per file and match the file name to that function. `src/public/*.ps1` files keep exactly one top-level public function; when a public command needs supporting logic, extract it to `src/private/` instead of adding more top-level functions to the public file. In `src/private/`, additional related functions may stay only as same-file top-level support helpers called by that file's entry function, and PowerShell functions must not declare nested functions inside their bodies. - Use `.github/instructions/code-quality-matrix.instructions.md` as the best-effort source-code maintainability guidance while shaping `src/**/*.ps1`. - Use `.github/instructions/psscriptanalyzer.instructions.md` as the ScriptAnalyzer workflow source of truth while changing PowerShell code or analyzer helpers. +- Follow the CI parity and readiness gate in `.github/instructions/repository-conventions.instructions.md`: inspect affected workflows before implementation, review test portability and dependency delivery, and distinguish local checks from exact-revision hosted matrix results before handoff. - Add or update tests that follow the naming convention `tests/public/.Tests.ps1` for public commands and source-mirrored paths such as `tests/private//.Tests.ps1` for private helpers, one test file per source file, and keep valid PlatyPS-compatible help docs for the changed behavior using the Microsoft.PowerShell.PlatyPS cmdlets instead of hand-written help structure. - Every new public entry point must add its matching help file in the same change. - Before handoff, review every changed or generated text file and normalize it to exactly one trailing newline with no extra blank lines at the bottom. diff --git a/.github/agents/test-engineer.agent.md b/.github/agents/test-engineer.agent.md index 3698555..4fdd2ac 100644 --- a/.github/agents/test-engineer.agent.md +++ b/.github/agents/test-engineer.agent.md @@ -36,7 +36,7 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an ## Constraints -- 1. Use `Invoke-NovaTest` when the change affects only in-memory logic with no module-load or file-system dependency. 2. Escalate to `Test-NovaBuild` when the change affects module manifest, exported commands, or build artifacts. 3. Run the full repo quality loop only when step 2 passes and a quality tooling regression is still open. +- Start with focused `Invoke-NovaTest` validation and use `Test-NovaBuild` for built-module/integration behavior. Run the required full repository quality loop for code or test changes regardless of whether a quality tooling regression remains open. Follow the CI parity and readiness gate in `.github/instructions/repository-conventions.instructions.md`; local success does not qualify other platforms or dependency environments. - Passing tests are not enough if any quality tooling hotspot score for a changed file worsens compared to the baseline on the default branch. - If an existing fixture or support helper in `tests/*TestSupport.ps1` covers at least 80% of the setup needed, extend it rather than creating a new file. Only create a new support file when no existing helper addresses the scenario. - Do not group unrelated source files into one broad test file when mirrored `tests/public`, `tests/private`, or `tests/classes` ownership is possible. For source files outside `public`, `private`, and `classes` subdirectories, place tests in a `tests//` folder that mirrors the source path. If no matching subdirectory exists, create it rather than placing the test in the root `tests/` folder. @@ -55,6 +55,7 @@ Improve or maintain the repository's Pester coverage, coverage-gate behavior, an - The touched tests are readable and low-duplication. - Validation uses `Invoke-NovaTest` for unit execution and `Test-NovaBuild` for build-validation integration execution. - Validation and quality tooling implications are addressed. +- Local and hosted validation evidence is reported separately under the repository CI parity gate; every required matrix lane and aggregate gate must pass for the exact revision before claiming CI or PR readiness. - The pre-commit quality tooling safeguard is clean before the work is treated as commit-ready when local quality tooling is available. If local quality tooling is unavailable, document the gap in a `# TODO: verify quality tooling` comment on the PR description and do not block the commit, but treat the item as open. ## Must not do diff --git a/.github/instructions/repository-conventions.instructions.md b/.github/instructions/repository-conventions.instructions.md index aa81d74..2de1dca 100644 --- a/.github/instructions/repository-conventions.instructions.md +++ b/.github/instructions/repository-conventions.instructions.md @@ -45,6 +45,25 @@ If quality tooling is unavailable locally, continue with the normal repository v If the only files changed are `.md` files and none of those files contain embedded code blocks that are executed as part of a test or build step, executable validation may be skipped. +## CI parity and readiness + +- Before changing code or tests, inspect the active `.github/workflows/` definitions and their owning `scripts/build/ci/` helpers. Identify the required OS, architecture, PowerShell versions, dependency provisioning and checks affected by the change; shared tests can break a platform even when production code did not change. +- Run the focused checks and the required local quality loop. Local `run.ps1` success is not matrix qualification. Use the configured lane entrypoint on a matching host/runtime where available, preserving dependency isolation and failure semantics; never spoof runner identity or bypass host guards. +- Review changed tests for portability, including path separators, casing, filesystem ordering and platform-specific commands. Build expected filesystem paths with `Join-Path` and compare the intended path contract, not a string containing the development host's separator. Preserve exact identity and side-effect assertions. +- Before raising a dependency minimum, verify that CI's configured provider and version-selection rules can supply a compatible artifact. Record the actual imported version and path in the test process, not just the launcher's installed-module inventory; a locally built or installed sibling module does not prove availability on a clean runner. An unavailable dependency blocks consumer readiness until an upstream release or explicit CI artifact-delivery change is separately authorized. Do not lower the minimum, add fallback imports, publish packages or modify global module installations to hide the gap. +- Report local and hosted evidence separately: tested revision/content state, OS/architecture, actual PowerShell and dependency versions, exact commands, test/coverage results and relevant run/job URLs. Mark unavailable hosts/runtimes, skipped required tests, interrupted commands and missing artifacts as unverified, not passed. +- Inspect every required hosted job and the aggregate gate for the exact revision and run attempt before claiming CI or PR readiness. An earlier revision's green run, a green quality job or four passing lanes cannot qualify a failing six-lane matrix. Uncommitted fixes remain pending hosted verification; a local handoff must say so. +- Trace failures to the first failing assertion or provisioning step before changing code. Keep regression assertions and thresholds intact; do not skip Windows tests, weaken comparisons, retry until green without diagnosis, or treat downstream missing-phase/artifact errors as the primary defect. + +For PSModuleUpdate, `.github/workflows/ci.yml` and `scripts/build/ci/CiLanes.json` +define six required platform/runtime lanes. Reproduce applicable local lanes with +the absolute selected PowerShell executable and +`./scripts/build/ci/Invoke-PlatformLane.ps1 -LaneId ` on a matching host. +Preserve its owned dependency root, runtime/test evidence and cleanup; do not +substitute ambient modules or direct Pester. Both Windows lanes need actual +Windows execution; macOS/Linux results cannot establish their success. Check +`ci-gate` and the separate test/coverage workflow for the tested revision. + ## Documentation review - Review `README.md`, `CONTRIBUTING.md`, `CHANGELOG.md`, and `RELEASE_NOTE.md` after any change that adds, removes, or alters public-facing behavior, commands, parameters, or documented workflows. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a50c83f..020f6f1 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,11 @@ Before opening a pull request: ## Agentic Copilot workflow +Follow the [CI parity and readiness gate](.github/instructions/repository-conventions.instructions.md#ci-parity-and-readiness) +for every implementation or test change. A local pass is not a six-lane matrix +pass; report the tested runtime/dependencies and any pending or failing hosted +jobs for the exact revision. + Recommended flow: 1. **Design** diff --git a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 index 54d104b..2723dd5 100644 --- a/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 +++ b/tests/private/identity/Get-InstalledModuleIdentityList.Tests.ps1 @@ -25,7 +25,7 @@ Describe 'Get-InstalledModuleIdentityList' -Tag 'PMU' { $identity = @(Get-InstalledModuleIdentityList 'FixtureModule' 'AllUsers') $identity.Count | Should -Be 1 $identity[0].FullVersion | Should -Be '0.1.0' - $identity[0].ManifestPath | Should -Be "$path/FixtureModule.psd1" + $identity[0].ManifestPath | Should -Be (Join-Path $path 'FixtureModule.psd1') "$moduleRoot/en-US", "$moduleRoot/resources", "$moduleRoot/FixtureModule.psd1" | ForEach-Object { $_ | Should -Exist } }