Skip to content

Enhance module compatibility: support nested module resolution slash notation#2700

Open
wetwicky wants to merge 1 commit intopester:mainfrom
wetwicky:feature/nested-module
Open

Enhance module compatibility: support nested module resolution slash notation#2700
wetwicky wants to merge 1 commit intopester:mainfrom
wetwicky:feature/nested-module

Conversation

@wetwicky
Copy link
Copy Markdown

@wetwicky wetwicky commented May 5, 2026

PR Summary

Enable users to specify a nested module unambiguously in -ModuleName parameters across all Pester mocking and scoping commands (Mock, InModuleScope, Should-Invoke, Should-NotInvoke) using rooted nested-module notation ('RootModuleName/NestedModuleName' and 'RootModuleName\NestedModuleName'). When this notation is detected, resolve the root module first, then locate the nested module within it and return its session state. Plain module names (without slash notation) must continue to work without any change in behavior.

PR Checklist

  • PR has meaningful title
  • Summary describes changes
  • PR is ready to be merged
    • If not, use the arrow next to Create Pull Request to mark it as a draft. PR can be marked Ready for review when it's ready.
  • Tests are added/update (if required)
  • Documentation is updated/added (if required)

Domain Concept Identification

Existing Concepts (from codebase)

  • ModuleName: A string parameter accepted by Mock, Should-Invoke, Should-NotInvoke, and InModuleScope that identifies the target PowerShell module into which a mock is injected or within which test code is executed. Currently expects a simple module name (e.g., "Pester", "MyModule").
  • Get-CompatibleModule: Internal Pester function (src/functions/InModuleScope.ps1) that resolves a module name string to a loaded PSModuleInfo object via Get-Module -Name $ModuleName -All. Used by both InModuleScope and Resolve-Command (in Mock.ps1). It is the single gateway for module resolution throughout the mocking and scoping system.
  • Resolve-Command: Internal function (src/functions/Mock.ps1) that resolves the command to be mocked within a module's session state. Delegates module lookup entirely to Get-CompatibleModule.
  • TargetModule: The resolved module name stored on MockBehavior and context info objects, used as part of the mock table key ("$ModuleName||$CommandName") to track call history per module scope.
  • NestedModules: A PowerShell module manifest concept (psd1) where a root/manifest module loads subordinate script modules. The nested modules share the root's session state when loaded via a manifest. Pester already has a test (tst/functions/InModuleScope.Tests.ps1, line ~325) demonstrating InModuleScope working against manifest modules that have nested modules.
  • RootModule (manifest key): In Pester.psd1, RootModule = 'Pester.psm1'. In the PowerShell module system, a manifest's RootModule is the primary script module; NestedModules lists additional modules loaded alongside it.

New Concepts Required

  • ModuleName slash-notation: A RootModule/NestedModule string format (e.g., "Pester/Pester.Runtime") to be parsed by Get-CompatibleModule (and propagated through Resolve-Command) that uniquely identifies a nested module within a specific root module. This concept does not yet exist anywhere in the Pester codebase.
  • Module disambiguation path: The resolution algorithm that, given RootModule/NestedModule, first finds the root module, then narrows the loaded NestedModules list to find the specific nested module, returning its PSModuleInfo — enabling disambiguation when the same nested module name appears under different root modules.

Key Business Rules

  • Format rule: When ModuleName contains exactly one /, the left side is the root/manifest module name and the right side is the nested module name. A plain name (no /) retains current behavior — no breaking change.
  • Root must exist: The root module named by the left side must be currently loaded and be of type Script or Manifest; otherwise error with a clear message.
  • Nested must be a child: The nested module named by the right side must be found in the root module's NestedModules list or otherwise be a module that was loaded as a consequence of importing the root; otherwise error.
  • Ambiguity resolution: If multiple root modules share the same name, the slash notation still requires the root to be unambiguous (same rule as today for plain names). The value-add is when the nested module name is ambiguous across different roots.
  • Consistency: The resolved TargetModule stored on MockBehavior must remain a plain module name (or the full slash path — TBD, see Key Design Decisions), so that internal comparisons such as $ModuleName -eq $command.Mock.Hook.OriginalCommand.Module.Name remain correct.

Strategic Approach

Solution Direction

  • Parse ModuleName for the RootModule/NestedModule separator in Get-CompatibleModule (the single module-resolution gateway). When detected, split on the first /, resolve the root module, then look up the nested module within it. Return the nested module's PSModuleInfo. All callers of Get-CompatibleModuleInModuleScope and Resolve-Command — automatically gain the capability.
  • InModuleScope and external-API functions (Mock, Should-Invoke, Should-NotInvoke) accept [string] $ModuleName unchanged; no parameter type changes are needed.

Key Design Decisions

  • Where to parse the slash notation — Options: (a) in Get-CompatibleModule only, (b) at each call site. → Recommendation: Option (a). Get-CompatibleModule is the sole resolution gateway; centralising the logic there avoids duplication and ensures InModuleScope, Mock, and assert commands all behave consistently without touching call sites.

  • What to store as TargetModule on MockBehavior — Options: (a) store the plain nested module name, (b) store the full RootModule/NestedModule path. The stored value is used in the mock call-history key ("$ModuleName||$CommandName") and in comparisons like $ModuleName -eq $command.Mock.Hook.OriginalCommand.Module.Name. PowerShell's Module.Name is always a plain name. → Recommendation: Store the resolved nested module's plain Name (e.g., "Pester.Runtime"), not the slash path. This keeps internal comparisons correct. Resolve-Command already sets TargetModule = $ModuleName after calling Get-CompatibleModule; the resolved plain name must be propagated back to the callers, meaning Resolve-Command should capture $module.Name (the resolved nested module name) and use that as TargetModule.

  • Nested module lookup strategy — Options: (a) iterate $rootModule.NestedModules, (b) call Get-Module -Name $nestedModuleName and filter to those where the RootModule* parent is the resolved root, (c) use Get-Module -All and filter by checking $_.Path starts with the root module's directory. → Recommendation: Option (a) — directly enumerate $rootModule.NestedModules and match by name. This is most precise and does not depend on path assumptions. It also mirrors how PowerShell itself tracks nested module relationships.

Alternatives Considered

  • Add a separate -NestedModuleName parameter to Mock, InModuleScope, etc.: Rejected because it requires changes to all public API surfaces, breaks existing scripts that splat parameters, increases complexity for consumers, and creates two mechanisms for the same concept.
  • Use Get-Module -Name $nestedName and disambiguate by checking parent: Partially viable but fragile — PowerShell's module graph doesn't always expose a clear parent link from a nested module back to its manifest. Direct enumeration of $rootModule.NestedModules is more reliable.

Risk & Gap Analysis

Requirement Ambiguities

  • What constitutes the "root": The left side of / — is it always the manifest module name, or can it also be any parent psm1 that imported nested modules explicitly? Modules imported via Import-Module with -NestedModules behave differently from manifest-defined nesting. Needs clarification.
  • Slash in module names: PowerShell module names do not normally contain /, but this is not enforced by the module system. If a module is legitimately named A/B, the parsing would break. The probability is extremely low, but the edge case should be documented.

Edge Cases

  • Single slash with no right side ("Pester/") or no left side ("/Runtime"): Parser must validate and throw a descriptive error rather than silently producing unexpected behavior.
  • Root module found but nested module not found within it: Must throw an error that names both the root and the attempted nested module name, rather than a generic Get-Module error.
  • Nested module already loaded as a top-level module (imported independently in the same session alongside being a nested module of a root): The slash notation disambiguates which instance to use. The algorithm must return the instance that is nested under the specified root, not any independently loaded copy.
  • Root module has not been imported yet: Get-Module -Name $rootName returns nothing. Error message should guide the user to import it.
  • Manifest modules whose RootModule key is a nested module: Pester.psd1 sets RootModule = 'Pester.psm1'. When someone writes Pester/Pester.psm1, the right side matches the RootModule value, not a NestedModules entry — edge case that may need consideration.

Technical Risks

  • TargetModule key mismatch in MockTable: The mock call-history hashtable is keyed on "$ModuleName||$CommandName". If the caller passes "Pester/Pester.Runtime" to Mock and the resolved TargetModule stored is "Pester.Runtime", but Should-Invoke is later called with ModuleName = "Pester/Pester.Runtime", the lookup must also resolve and use the plain name. Resolve-Command is called by Should-Invoke too; as long as it always normalises to the plain nested name, the key matches. Risk is low if the resolution always normalises — but needs a test.
  • $ModuleName -eq $command.Mock.Hook.OriginalCommand.Module.Name comparison in Resolve-Command (lines 735, 755): This compares TargetModule to OriginalCommand.Module.Name. If TargetModule is stored as the slash path rather than the plain name, these comparisons silently fail, breaking IsFromTargetModule. Medium risk — mitigated by the decision to store the plain name.
  • EscapeSingleQuotedStringContent $ModuleName used in the mock bootstrap script body (Mock.ps1 line 170): The bootstrap script embeds ModuleName in a single-quoted string literal. The / character is safe in single-quoted strings, but if the slash notation is used as-is (rather than the resolved plain name), the embedded string literal would be 'Pester/Pester.Runtime' — still valid PowerShell but would break the Invoke_Mock dispatch which uses ModuleName as a key. Low-medium risk — resolved by always normalising before embedding.

Acceptance Criteria Coverage

AC# Description Addressable? Gaps/Notes
1 Mock -ModuleName 'Root/Nested' Get-Item {} sets up a mock in the nested module Yes Requires Get-CompatibleModule to parse and resolve; TargetModule must be normalised
2 InModuleScope 'Root/Nested' { ... } executes in the nested module's session state Yes Same Get-CompatibleModule change covers this
3 Should-Invoke -ModuleName 'Root/Nested' Get-Item correctly finds call history Yes Depends on consistent normalisation of TargetModule to plain name
4 Plain ModuleName (no /) continues to work unchanged Yes Conditional branch — no existing code path changes
5 Clear error when root module not found Yes Must add explicit error message for split-notation path
6 Clear error when nested module not found under root Yes Must add explicit error message — not covered by current Get-Module error
Multi-level nesting (A/B/C) Yes Must support 1 to N level

@wetwicky wetwicky changed the title Enhance module compatibility: support nested module resolution slash notationusing … Enhance module compatibility: support nested module resolution slash notation May 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant