Cannot modify a script-scoped variable from inside a function - powershell

I am currently making a script which is supposed to connect to 42 different local servers and getting the Users of a specific group (fjärrskrivbordsanvändare(Remote desktop users in swedish :D)) from active directory. After it has gotten all the users from the server it has to export the users to a file on MY desktop
The csv file has to look like this:
Company;Users
LawyerSweden;Mike
LawyerSweden;Jennifer
Stockholm Candymakers;Pedro
(Examples)
etc.
Here's the code as of now:
cls
$MolnGroup = 'fjärrskrivbordsanvändare'
$ActiveDirectory = 'activedirectory'
$script:CloudArray
Set-Variable -Name OutputAnvandare -Value ($null) -Scope Script
Set-Variable -Name OutputDomain -Value ($null) -Scope Script
function ReadInfo {
Write-Host("A")
Get-Variable -Exclude PWD,*Preference | Remove-Variable -EA 0
if (Test-Path "C:\file\frickin\path.txt") {
Write-Host("File found")
}else {
Write-Host("Error: File not found, filepath might be invalid.")
Exit
}
$filename = "C:\File\Freakin'\path\super.txt"
$Headers = "IPAddress", "Username", "Password", "Cloud"
$Importedcsv = Import-csv $filename -Delimiter ";" -Header $Headers
$PasswordsArray += #($Importedcsv.password)
$AddressArray = #($Importedcsv | ForEach-Object { $_.IPAddress } )
$UsernamesArray += #($Importedcsv.username)
$CloudArray += #($Importedcsv.cloud)
GetData
}
function GetData([int]$p) {
Write-Host("B")
for ($row = 1; $row -le $UsernamesArray.Length; $row++)
{
# (If the customer has cloud-service on server, proceed)
if($CloudArray[$row] -eq 1)
{
# Code below uses the information read in from a file to connect pc to server(s)
$secstr = New-Object -TypeName System.Security.SecureString
$PasswordsArray[$row].ToCharArray() | ForEach-Object {$secstr.AppendChar($_)}
$cred = new-object -typename System.Management.Automation.PSCredential -argumentlist $UsernamesArray[$row], $secstr
# Runs command on server
$OutputAnvandare = Invoke-Command -computername $AddressArray[$row] -credential $cred -ScriptBlock {
Import-Module Activedirectory
foreach ($Anvandare in (Get-ADGroupMember fjärrskrivbordsanvändare))
{
$Anvandare.Name
}
}
$OutputDomain = Invoke-Command -computername $AddressArray[$row] -credential $cred -ScriptBlock {
Import-Module Activedirectory
foreach ($Anvandare in (Get-ADGroupMember fjärrskrivbordsanvändare))
{
gc env:UserDomain
}
}
$OutputDomain + $OutputAnvandare
}
}
}
function Export {
Write-Host("C")
# Variabler för att bygga up en CSV-fil genom Out-File
$filsökväg = "C:\my\file\path\Coolkids.csv"
$ColForetag = "Company"
$ColAnvandare = "Users"
$Emptyline = "`n"
$delimiter = ";"
for ($p = 1; $p -le $AA.Length; $p++) {
# writes out columns in the csv file
$ColForetag + $delimiter + $ColAnvandare | Out-File $filsökväg
# Writes out the domain name and the users
$OutputDomain + $delimiter + $OutputAnvandare | Out-File $filsökväg -Append
}
}
ReadInfo
Export
My problem is, I can't export the users or the domain. As you can see i tried to make the variables global to the whole script, but $outputanvandare and $outputdomain only contains the information i need inside of the foreach loop. If I try to print them out anywhere else, they're empty?!

This answer focuses on variable scoping, because it is the immediate cause of the problem.
However, it is worth mentioning that modifying variables across scopes is best avoided to begin with; instead, pass values via the success stream (or, less typically, via by-reference variables and parameters ([ref]).
To expound on PetSerAl's helpful comment on the question: The perhaps counter-intuitive thing about PowerShell variable scoping is that:
while you can see (read) variables from ancestral (higher-up) scopes (such as the parent scope) by referring to them by their mere name (e.g., $OutputDomain),
you cannot modify them by name only - to modify them you must explicitly refer to the scope that they were defined in.
Without scope qualification, assigning to a variable defined in an ancestral scope implicitly creates a new variable with the same name in the current scope.
Example that demonstrates the issue:
# Create empty script-level var.
Set-Variable -Scope Script -Name OutputDomain -Value 'original'
# This is the same as:
# $script:OutputDomain = 'original'
# Declare a function that reads and modifies $OutputDomain
function func {
# $OutputDomain from the script scope can be READ
# without scope qualification:
$OutputDomain # -> 'original'
# Try to modify $OutputDomain.
# !! Because $OutputDomain is ASSIGNED TO WITHOUT SCOPE QUALIFICATION
# !! a NEW variable in the scope of the FUNCTION is created, and that
# !! new variable goes out of scope when the function returns.
# !! The SCRIPT-LEVEL $OutputDomain is left UNTOUCHED.
$OutputDomain = 'new'
# !! Now that a local variable has been created, $OutputDomain refers to the LOCAL one.
# !! Without scope qualification, you cannot see the script-level variable
# !! anymore.
$OutputDomain # -> 'new'
}
# Invoke the function.
func
# Print the now current value of $OutputDomain at the script level:
$OutputDomain # !! -> 'original', because the script-level variable was never modified.
Solution:
There are several ways to add scope qualification to a variable reference:
Use a scope modifier, such as script in $script:OutputDomain.
In the case at hand, this is the simplest solution:
$script:OutputDomain = 'new'
Note that this only works with absolute scopes global, script, and local (the default).
A caveat re global variables: they are session-global, so a script assigning to a global variable could inadvertently modify a preexisting global variable, and, conversely, global variables created inside a script continue to exist after the script terminates.
Use Get/Set-Variable -Scope, which - in addition to supporting the absolute scope modifiers - supports relative scope references by 0-based index, where 0 represents the current scope, 1 the parent scope, and so on.
In the case at hand, since the script scope is the next higher scope,
Get-Variable -Scope 1 OutputDomain is the same as $script:OutputDomain, and
Set-Variable -Scope 1 OutputDomain 'new' equals $script:OutputDomain = 'new'.
(A rarely used alternative available inside functions and trap handlers is to use [ref], which allows modifying the variable in the most immediate ancestral scope in which it is defined: ([ref] $OutputDomain).Value = 'new', which, as PetSerAl points out in a comment, is the same as (Get-Variable OutputDomain).Value = 'new')
For more information, see:
Get-Help about_Variables
Get-Help about_Scopes
Finally, for the sake of completeness, Set-Variable -Option AllScope is a way to avoid having to use scope qualification at all (in all descendent scopes), because effectively then only a single variable by that name exists, which can be read and modified without scope qualification from any (descendent) scope.
# By defining $OutputDomain this way, all descendent scopes
# can both read and assign to $OutpuDomain without scope qualification
# (because the variable is effectively a singleton).
Set-Variable -Scope Script -Option AllScope -Name OutputDomain
However, I would not recommend it (at least not without adopting a naming convention), as it obscures the distinction between modifying local variables and all-scope variables:
in the absence of scope qualification, looking at a statement such as $OutputDomain = 'new' in isolation, you cannot tell if a local or an all-scope variable is being modified.

Since you've mentioned that you want to learn, I hope you'll pardon my answer, which is a bit longer than normal.
The issue that's impacting you here is PowerShell Variable Scoping. When you're commiting the values of $outputAvandare and $outputDomain, they only exist for as long as that function is running.
Function variables last until the function ends.
Script variables last until the script ends.
Session/global variables last until the session ends.
Environmental variable persist forever.
If you want to get the values out of them, you could make them Global variables instead, using this syntax:
$global:OutputAnvandare = blahblahblah
While that would be the easiest fix for your code, Global variables are frowned upon in PowerShell, since they subvert the normal PowerShell expectations of variable scopes.
Much better solution :)
Don't be dismayed, you're actually almost there with a really good solution that conforms to PowerShell design rules.
Today, your GetData function grabs the values that we want, but it only emits them to the console. You can see this in this line on GetData:
$OutputDomain + $OutputAnvandare
This is what we'd call emitting an object, or emiting data to the console. We need to STORE this data instead of just writing it. So instead of simply calling the function, as you do today, do this instead:
$Output = GetData
Then your function will run and grab all the AD Users, etc, and we'll grab the results and stuff them in $output. Then you can export the contents of $output later on.

Related

Bulk-Assign PowerShell Variables using Multi-Level [PSCustomObject]

TLDR
I'm trying to create a function that will take a Multi-Level [PSCustomObject], extract the Key/Value pairs (strings only), and use them to declare Individual Global Variables using Set-Variable.
Current Code
Set-Variable -Name 'NSOneDrive' -Value "D:\OneDrive - New Spectrum"
$StrykerDirs = [PSCustomObject]#{
'OneDrive' = [PSCustomObject]#{
'NSOneDrive' = "D:\OneDrive - New Spectrum"
'MyOneDrive' = "D:\OneDrive"
}
'Dev' = [PSCustomObject]#{
'DevDir' = "${NSOneDrive}\Dev"
'DevToolsDir' = [PSCustomObject]#{
'DevTools' = "${NSOneDrive}\Dev\_DevTools"
'Terminals' = [PSCustomObject]#{
'DT_Terminals' = "${NSOneDrive}\Dev\_DevTools\terminals"
'DT_PowerShell' = "${NSOneDrive}\Dev\_DevTools\terminals\PowerShell"
}
'Editors' = [PSCustomObject]#{
'DT_Editors' = "${NSOneDrive}\Dev\_DevTools\.editors"
}
}
'ProjectsDir' = [PSCustomObject]#{
'NSProjects' = "${NSOneDrive}\Projects\NewSpectrum"
'MyProjects' = "${NSOneDrive}\Projects\Personal"
}
}
}
$StrykerDirs |
ConvertTo-JSON -Depth 25 |
Tee-Object -FilePath ".\JSON\Stryker-Paths.json"
function Set-DirAliases {
[CmdletBinding()]
# I might add parameters after I know how to make the 'Process' work
Begin {
# Begin Process Block
}
Process {
ForEach ( $dir in $StrykerDirs ) {
where ( $_.GetType() -eq 'String' ) |
Set-Variable -Name "${key}" -Value "${value}"
# I know ${key} and ${value} won't work, but I'm not sure how to properly fill them
}
}
End {
# End Process Block
}
}
Goals
Simplifying Set-Location Navigation
First and foremost I obviously need to figure out how to make the above Process block work. Once I do, I'll be able to easily declare Directory Variables for use with Set-Location. This is only for streamlining variable declarations so I don't have to repeatedly declare them with a messy barrage of individual Set-Variable commands while also avoiding the use of long (sometimes very long) $Object.PropertyName 'variables'.
After I get a handle on this script, I'll be able to finish several other scripts and functions that use (more or less) the same basic process.
Add to $PROFILE
This particular script is going to be part of a 'Startups' section in my default $PROFILE (Microsoft.PowerShell_profile.ps1) so I can set the Directory Variables in-bulk and keep the $PROFILE script itself nice and clean.
The other scripts that I mentioned anbove are also going to be included in my $PROFILE Startups.
JSON Output
The script also exports a .json file so that, among other things, I can (hopefully) repeat the process down the road in my WSL Bash Profiles.
Param() Functionality
Eventually I want to add a Param() block so the function can be used outside of the script as well.

String failing to concatenate in PowerShell [duplicate]

I'm currently editing our new employee script for AD and I am running into an issue, I added 2 radio buttons for the home folder, one radio button will update the BaseHomeFolderPath to Path1 and the second will update it to Path2, I export the variable to a csv to verify that it works but it keeps showing up blank. I don't know if it is a scope issue or what I am doing wrong, any help will be greatly appreciated! Below is a copy of the code.
I tried using $script: and it didn't work either.
$BaseHomeFolderPath = ''
Set-Variable -Name $BaseHomeFolderPath -Scope Global
$radiobuttonAtlas_MouseClick = [System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path1\users'
}
$radiobuttonCerberus_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path2\users'
}
$buttonRun_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$TestValue = New-Object System.Object
$TestValue | Add-Member -MemberType NoteProperty -Name "Path" -Value $BaseHomeFolderPath
$TestValue | Export-CSV -NoTypeInformation -Path "C:\Users\testuser\Desktop\Testcsv.csv"
}
I expect the BaseHomeFolderPath variable to be updated to whichever radio button is selected.
tl;dr
In order to update the $BaseHomeFolderPath variable in the script scope (or any scope other than the local one), you must reference it in that scope explicitly:
$script:BaseHomeFolderPath = '\\path1\users'
Otherwise, without a scope specifier such as $script:, you'll implicitly create a new variable by that name in the current scope, which in your case is the child scope in which your event handlers run.
In PowerShell, when you assign to a variable with $var = ..., you either:
update a preexisting variable that was created in the same scope.
or implicitly create a new variable in the current scope.
The tricky part is that even though child scopes see variables created in parent scopes and can get their value by name only, assigning to them by name only creates a new, scope-local variable, and that new variable then shadows the original one in the current scope and all child scopes.
A simple demonstration, using call operator & to execute a script block ({ ... }) in a child scope:
$var = 'parent'
"in parent: before: $var"
& {
"in child: before: $var" # sees $var from parent scope
$var = 'child' # creates new $var in current scope
"in child: after: $var" # sees new $var, which shadows the parent's
}
"in parent: after: $var" # still has original value
This prints:
in parent: before: parent
in child: before: parent
in child: after: child
in parent: after: parent
Note:
The behavior also applies to the implicit assignment that occurs with the ++ and -- operators (given that, e.g., ++$var is the same as $var += 1)
See this answer for an example of where this can constitute a pitfall.
In addition to fixed-target scope specifiers $script: and $global:, you can use the Get-Variable / Set-Variable cmdlets with the -Scope parameter to target variables in scopes relative to the current one (up the call stack; e.g., -Scope 1 refers to the parent scope).
To promote modularity and maintainability, it's generally better to avoid accessing variables across scope boundaries - best to pass values around instead.
For more information, see:
Get-Help about_Scopes
The last section of this answer, which provides a concise summary.

Why using command switch with a variable inside returns empty variable when using a windows form? [duplicate]

I'm currently editing our new employee script for AD and I am running into an issue, I added 2 radio buttons for the home folder, one radio button will update the BaseHomeFolderPath to Path1 and the second will update it to Path2, I export the variable to a csv to verify that it works but it keeps showing up blank. I don't know if it is a scope issue or what I am doing wrong, any help will be greatly appreciated! Below is a copy of the code.
I tried using $script: and it didn't work either.
$BaseHomeFolderPath = ''
Set-Variable -Name $BaseHomeFolderPath -Scope Global
$radiobuttonAtlas_MouseClick = [System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path1\users'
}
$radiobuttonCerberus_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path2\users'
}
$buttonRun_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$TestValue = New-Object System.Object
$TestValue | Add-Member -MemberType NoteProperty -Name "Path" -Value $BaseHomeFolderPath
$TestValue | Export-CSV -NoTypeInformation -Path "C:\Users\testuser\Desktop\Testcsv.csv"
}
I expect the BaseHomeFolderPath variable to be updated to whichever radio button is selected.
tl;dr
In order to update the $BaseHomeFolderPath variable in the script scope (or any scope other than the local one), you must reference it in that scope explicitly:
$script:BaseHomeFolderPath = '\\path1\users'
Otherwise, without a scope specifier such as $script:, you'll implicitly create a new variable by that name in the current scope, which in your case is the child scope in which your event handlers run.
In PowerShell, when you assign to a variable with $var = ..., you either:
update a preexisting variable that was created in the same scope.
or implicitly create a new variable in the current scope.
The tricky part is that even though child scopes see variables created in parent scopes and can get their value by name only, assigning to them by name only creates a new, scope-local variable, and that new variable then shadows the original one in the current scope and all child scopes.
A simple demonstration, using call operator & to execute a script block ({ ... }) in a child scope:
$var = 'parent'
"in parent: before: $var"
& {
"in child: before: $var" # sees $var from parent scope
$var = 'child' # creates new $var in current scope
"in child: after: $var" # sees new $var, which shadows the parent's
}
"in parent: after: $var" # still has original value
This prints:
in parent: before: parent
in child: before: parent
in child: after: child
in parent: after: parent
Note:
The behavior also applies to the implicit assignment that occurs with the ++ and -- operators (given that, e.g., ++$var is the same as $var += 1)
See this answer for an example of where this can constitute a pitfall.
In addition to fixed-target scope specifiers $script: and $global:, you can use the Get-Variable / Set-Variable cmdlets with the -Scope parameter to target variables in scopes relative to the current one (up the call stack; e.g., -Scope 1 refers to the parent scope).
To promote modularity and maintainability, it's generally better to avoid accessing variables across scope boundaries - best to pass values around instead.
For more information, see:
Get-Help about_Scopes
The last section of this answer, which provides a concise summary.

Why won't Variable update?

I'm currently editing our new employee script for AD and I am running into an issue, I added 2 radio buttons for the home folder, one radio button will update the BaseHomeFolderPath to Path1 and the second will update it to Path2, I export the variable to a csv to verify that it works but it keeps showing up blank. I don't know if it is a scope issue or what I am doing wrong, any help will be greatly appreciated! Below is a copy of the code.
I tried using $script: and it didn't work either.
$BaseHomeFolderPath = ''
Set-Variable -Name $BaseHomeFolderPath -Scope Global
$radiobuttonAtlas_MouseClick = [System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path1\users'
}
$radiobuttonCerberus_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$BaseHomeFolderPath = '\\path2\users'
}
$buttonRun_MouseClick=[System.Windows.Forms.MouseEventHandler]{
#Event Argument: $_ = [System.Windows.Forms.MouseEventArgs]
#TODO: Place custom script here
$TestValue = New-Object System.Object
$TestValue | Add-Member -MemberType NoteProperty -Name "Path" -Value $BaseHomeFolderPath
$TestValue | Export-CSV -NoTypeInformation -Path "C:\Users\testuser\Desktop\Testcsv.csv"
}
I expect the BaseHomeFolderPath variable to be updated to whichever radio button is selected.
tl;dr
In order to update the $BaseHomeFolderPath variable in the script scope (or any scope other than the local one), you must reference it in that scope explicitly:
$script:BaseHomeFolderPath = '\\path1\users'
Otherwise, without a scope specifier such as $script:, you'll implicitly create a new variable by that name in the current scope, which in your case is the child scope in which your event handlers run.
In PowerShell, when you assign to a variable with $var = ..., you either:
update a preexisting variable that was created in the same scope.
or implicitly create a new variable in the current scope.
The tricky part is that even though child scopes see variables created in parent scopes and can get their value by name only, assigning to them by name only creates a new, scope-local variable, and that new variable then shadows the original one in the current scope and all child scopes.
A simple demonstration, using call operator & to execute a script block ({ ... }) in a child scope:
$var = 'parent'
"in parent: before: $var"
& {
"in child: before: $var" # sees $var from parent scope
$var = 'child' # creates new $var in current scope
"in child: after: $var" # sees new $var, which shadows the parent's
}
"in parent: after: $var" # still has original value
This prints:
in parent: before: parent
in child: before: parent
in child: after: child
in parent: after: parent
Note:
The behavior also applies to the implicit assignment that occurs with the ++ and -- operators (given that, e.g., ++$var is the same as $var += 1)
See this answer for an example of where this can constitute a pitfall.
In addition to fixed-target scope specifiers $script: and $global:, you can use the Get-Variable / Set-Variable cmdlets with the -Scope parameter to target variables in scopes relative to the current one (up the call stack; e.g., -Scope 1 refers to the parent scope).
To promote modularity and maintainability, it's generally better to avoid accessing variables across scope boundaries - best to pass values around instead.
For more information, see:
Get-Help about_Scopes
The last section of this answer, which provides a concise summary.

Delay-bind script block does not work when function is exported from module

I have following function:
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject,
[Object] $ScriptBlock
)
process {
$value = Invoke-Command -ScriptBlock $ScriptBlock
Write-Host "Script: $value"
}
}
When I define this function directly in script and pipe input into it I get following result which is expected:
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }
# Outputs: "Script: Test"
But when I define this function inside module and export it with Export-ModuleMember -Function PipeScript then pipeline variable $_ inside script block is always null:
Import-Module PipeModule
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name }
# Outputs: "Script: "
Full repro is available at: https://github.com/lpatalas/DelayBindScriptBlock
Can someone explain this behaviour?
Tip of the hat to PetSerAl for all his help.
Here's a simple solution, but note that it runs the script block directly in the caller's scope, i.e. it effectively "dot-sources", which allows modification of the caller's variables.
By contrast, your use of Invoke-Command runs the script block in a child scope of the caller's scope - if that is truly the intent, see the variant solution below.
"Dot-sourcing" the script block is also what standard cmdlets such as Where-Object and ForEach-Object do.
# Define the function in an (in-memory) module.
# An in-memory module is automatically imported.
$null = New-Module {
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject
,
[scriptblock] $ScriptBlock
)
process {
# Use ForEach-Object to create the automatic $_ variable
# in the script block's origin scope.
$value = ForEach-Object -Process $ScriptBlock -InputObject $InputObject
# Output the value
"Script: $value"
}
}
}
# Test the function:
$var = 42; #{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 43 - the script block ran in the caller's scope.
The above outputs string Script: Test and 43 afterwards, proving that the input object was seen as $_ and that dot-sourcing worked ($var was successfully incremented in the caller's scope).
Here's a variant, via the PowerShell SDK, that runs the script block in a child scope of the caller's scope.
This can be helpful if you don't want the execution of the script block to accidentally modify the caller's variables.
It is the same behavior you get with the engine-level delay-bind script-block and calculated-property features - though it's unclear whether that behavior was chosen intentionally.
$null = New-Module {
function PipeScript {
param(
[Parameter(ValueFromPipeline)]
[Object] $InputObject
,
[scriptblock] $ScriptBlock
)
process {
# Use ScriptBlock.InvokeContext() to inject a $_ variable
# into the child scope that the script block runs in:
# Creating a custom version of what is normally an *automatic* variable
# seems hacky, but the docs do state:
# "The list of variables may include the special variables
# $input, $_ and $this." - see https://learn.microsoft.com/en-us/dotnet/api/system.management.automation.scriptblock.invokewithcontext
$value = $ScriptBlock.InvokeWithContext(
$null, # extra functions to define (none here)
[psvariable]::new('_', $InputObject) # actual parameter type is List<PSVariable>
)
# Output the value
"Script: $value"
}
}
}
# Test the function:
$var = 42
#{ Name = 'Test' } | PipeScript -ScriptBlock { $_.Name; ++$var }
$var # -> 42 - unaltered, because the script block ran in a child scope.
The above outputs string Script: Test, followed by 42, proving that the script block saw the input object as $_ and that variable $var - although seen in the script block, was not modified, due to running in a child scope.
The ScriptBlock.InvokeWithContext() method is documented here.
As for why your attempt didn't work:
Generally, script blocks are bound to the scope and scope domain in which they are created (except if they're created expressly as unbound script blocks, with [scriptblock]::Create('...')).
A scope outside of a module is part of the default scope domain. Every module has its own scope domain, and except for the global scope, which all scopes across all scope domains see, scopes in different scope domains do not see one another.
Your script block is created in the default scope domain, and when the module-defined function invokes it, the $_ is looked for in the scope of origin, i.e., in the (non-module) caller scope, where it isn't defined, because the automatic $_ variable is created by PowerShell on demand in the local scope, which is in the enclosing module's scope domain.
By using .InvokeWithContext(), the script block runs in a child scope of the caller's scope (as would be the case with .Invoke() and Invoke-Command by default), into which the above code injects a custom $_ variable so that the script block can reference it.
Providing better SDK support for these scenarios is being discussed in GitHub issue #3581.