From 387d84310011ce2d024be622855ea5506afb52e5 Mon Sep 17 00:00:00 2001 From: Andy Jordan <2226434+andyleejordan@users.noreply.github.com> Date: Mon, 15 Jun 2026 20:03:11 -0700 Subject: [PATCH] Resolve `evaluate`/watch variables from the most-local scope When a watch or `evaluate` request resolved a naked variable reference, `GetVariableFromExpression` searched the flat `variables` list, which is populated broadest-to-narrowest (global, then script, then local, then stack frames). `FirstOrDefault` therefore returned the global/parent-scope copy of a variable whenever the same name also existed in a more local scope, even though the Variables explorer and PowerShell itself show the local value. We now search the local, script, and global scope containers in that order first, falling back to the full flat list so names that only live in a frame still resolve. This matches PowerShell's own variable resolution semantics. Added a regression test (and `VariableScopeTest.ps1`) that shadows a variable across scopes and asserts the local value wins. Fixes #1882. Drafted by Copilot (Claude Opus 4.8). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/DebugAdapter/DebugService.cs | 11 +++++++++- .../Debugging/VariableScopeTest.ps1 | 5 +++++ .../Debugging/DebugServiceTests.cs | 21 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) create mode 100644 test/PowerShellEditorServices.Test.Shared/Debugging/VariableScopeTest.ps1 diff --git a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs index a32cd48da..596bf137a 100644 --- a/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs +++ b/src/PowerShellEditorServices/Services/DebugAdapter/DebugService.cs @@ -308,7 +308,16 @@ public async Task GetVariableFromExpression(string variable await debugInfoHandle.WaitAsync(cancellationToken).ConfigureAwait(false); try { - variableList = variables; + // Search the scope containers in order of narrowest to broadest scope (local, + // then script, then global) so that a variable defined in a more local scope + // correctly shadows one of the same name in a parent scope, matching + // PowerShell's own variable resolution. The flattened list of every variable is + // appended as a fallback so that names not present in those scopes (such as + // frame-specific variables) still resolve. See issue #1882. + variableList = new[] { localScopeVariables, scriptScopeVariables, globalScopeVariables } + .Where(container => container is not null) + .SelectMany(container => container.Children.Values) + .Concat(variables); } finally { diff --git a/test/PowerShellEditorServices.Test.Shared/Debugging/VariableScopeTest.ps1 b/test/PowerShellEditorServices.Test.Shared/Debugging/VariableScopeTest.ps1 new file mode 100644 index 000000000..2f6d66e83 --- /dev/null +++ b/test/PowerShellEditorServices.Test.Shared/Debugging/VariableScopeTest.ps1 @@ -0,0 +1,5 @@ +$scopeTestVariable = "from parent scope" +& { + $scopeTestVariable = "from local scope" + Write-Output $scopeTestVariable +} diff --git a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs index 3ba16008d..1d3960140 100644 --- a/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs +++ b/test/PowerShellEditorServices.Test/Debugging/DebugServiceTests.cs @@ -644,6 +644,27 @@ await debugService.SetLineBreakpointsAsync( Assert.False(var.IsExpandable); } + // Regression test for #1882: when a variable of the same name exists in both a parent + // scope and the current (more local) scope, evaluating it via a watch/evaluate request + // must return the most-local value, matching what's shown in the Variables explorer and + // PowerShell's own variable resolution. + [Fact] + public async Task DebuggerResolvesVariableFromMostLocalScope() + { + ScriptFile scopeScriptFile = GetDebugScript("VariableScopeTest.ps1"); + await debugService.SetLineBreakpointsAsync( + scopeScriptFile.FilePath, + new[] { BreakpointDetails.Create(scopeScriptFile.FilePath, 4) }); + + Task _ = ExecuteScriptFileAsync(scopeScriptFile.FilePath); + await AssertDebuggerStopped(scopeScriptFile.FilePath, 4); + + VariableDetailsBase resolved = await debugService.GetVariableFromExpression( + "$scopeTestVariable", CancellationToken.None); + Assert.NotNull(resolved); + Assert.Equal("\"from local scope\"", resolved.ValueString); + } + [Fact] public async Task DebuggerGetsVariables() {