diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 056f93e..8c64181 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -402,8 +402,13 @@ priorities. pin current-scope mutation, empty input, explicit input, phase order, and the differing no-input behavior of the two cmdlets. Ordinary assignment state transfer remains an atomic task 7.5b follow-up; task 7.5b is not - complete. Continue with in-process `Invoke-Command`; child - process/runspace jobs and parallel + complete. In-process `Invoke-Command` now distinguishes default child + variable/command scope from `-NoNewScope` current-scope flow while + propagating shared location and retaining synchronous/once region facts. + Pipelines with any supported synchronous execution region plus another + stateful stage withhold body facts that downstream initialization or + per-object interleaving can invalidate. + Child process/runspace jobs and parallel blocks; deferred breakpoint/event/completion actions; then unknown receiver and nested/adversarial matrices. Preserve script blocks proved to be data as opaque values, expose ambiguous bodies with incomplete diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 0928251..cb268c4 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -988,7 +988,7 @@ private readonly Dictionary> private long _locationStateMutationCount; private long _childScopeEscapeRiskCount; private bool _pipelineStageMayReceiveInput; - private bool _pipelineCallbacksMayInterleave; + private bool _pipelineStageEffectsMayReachRegionBodies; private PwshForEachValueAnalyzer( PwshParserOptions options, @@ -1220,6 +1220,15 @@ flow.OnFailure is AnalysisContext failure flow); } + if (binding.ParameterSet == PwshExecutionRegionParameterSet.InvokeInProcess) + { + return AnalyzeInProcessInvokeCommand( + binding, + regions[0], + regionInput, + flow); + } + var executionRegionEffectCount = _executionRegionEffectCount; var nonRegionStateMutationCount = _nonRegionStateMutationCount; var bodyFlow = AnalyzeExecutionRegionBody(regions[0].Body, regionInput); @@ -1230,6 +1239,48 @@ flow.OnFailure is AnalysisContext failure : flow; } + private PwshFlowResult AnalyzeInProcessInvokeCommand( + PwshExecutionRegionBindingResult binding, + ExecutionRegionSyntax region, + AnalysisContext input, + PwshFlowResult fallback) + { + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var locationStateMutationCount = _locationStateMutationCount; + var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; + var body = AnalyzeExecutionRegionBody(region.Body, input); + var locationMutated = _locationStateMutationCount > locationStateMutationCount; + var childScopeMayEscape = + _childScopeEscapeRiskCount > childScopeEscapeRiskCount; + _executionRegionEffectCount = executionRegionEffectCount + 1; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + + if (binding.HasNoNewScope) + { + return body.JoinedState is AnalysisContext bodyExit + ? PwshFlowResult.Both(bodyExit) + : fallback; + } + + _childScopeEscapeRiskCount = childScopeEscapeRiskCount + + (childScopeMayEscape ? 1 : 0); + var restoredExit = AnalysisContext.JoinNullable( + RestoreChildScopeExit( + body.OnSuccess, + input, + locationMutated, + childScopeMayEscape), + RestoreChildScopeExit( + body.OnFailure, + input, + locationMutated, + childScopeMayEscape)); + return restoredExit is AnalysisContext joined + ? PwshFlowResult.Both(joined) + : fallback; + } + private PwshFlowResult AnalyzePipelineCallbackRegions( PwshExecutionRegionBindingResult binding, IReadOnlyList regions, @@ -1362,7 +1413,7 @@ private PwshFlowResult AnalyzeExecutionRegionBody( { var enclosingStageMayReceiveInput = _pipelineStageMayReceiveInput; _pipelineStageMayReceiveInput = false; - var bodyInput = _pipelineCallbacksMayInterleave + var bodyInput = _pipelineStageEffectsMayReachRegionBodies ? input.Invalidate(unknownCwd: true) : input; var flow = AnalyzeBlock(body, bodyInput); @@ -1445,6 +1496,7 @@ private static bool IsSupportedSynchronousReceiver( binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && (binding.ParameterSet is PwshExecutionRegionParameterSet.MeasureExpression or PwshExecutionRegionParameterSet.TraceExpression or + PwshExecutionRegionParameterSet.InvokeInProcess or PwshExecutionRegionParameterSet.ForEachScriptBlock or PwshExecutionRegionParameterSet.WhereScriptBlock) && AllBindingsAreCompleteAndSynchronous(binding.Bindings); @@ -1606,8 +1658,10 @@ private PwshFlowResult AnalyzeList(CommandListSyntax list, AnalysisContext input private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input) { var stageInput = input; - var enclosingCallbacksMayInterleave = _pipelineCallbacksMayInterleave; - _pipelineCallbacksMayInterleave |= PipelineCallbacksMayInterleave(pipeline); + var enclosingStageEffectsMayReachBodies = + _pipelineStageEffectsMayReachRegionBodies; + _pipelineStageEffectsMayReachRegionBodies |= + PipelineStageEffectsMayReachRegionBodies(pipeline); try { for (var stageIndex = 0; stageIndex < pipeline.Stages.Count; stageIndex++) @@ -1644,35 +1698,43 @@ private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext } finally { - _pipelineCallbacksMayInterleave = enclosingCallbacksMayInterleave; + _pipelineStageEffectsMayReachRegionBodies = + enclosingStageEffectsMayReachBodies; } } - private static bool PipelineCallbacksMayInterleave(PipelineSyntax pipeline) + private static bool PipelineStageEffectsMayReachRegionBodies(PipelineSyntax pipeline) { - var hasCallback = false; + var hasPipelineSensitiveRegion = false; var statefulStageCount = 0; for (var index = 0; index < pipeline.Stages.Count; index++) { var stage = pipeline.Stages[index]; - hasCallback |= ContainsPipelineCallback(stage); + hasPipelineSensitiveRegion |= ContainsPipelineSensitiveExecutionRegion(stage); if (MayMutatePipelineState(stage)) { statefulStageCount++; } } - return hasCallback && statefulStageCount > 1; + return hasPipelineSensitiveRegion && statefulStageCount > 1; } - private static bool ContainsPipelineCallback(ShellSyntaxNode node) => + private static bool ContainsPipelineSensitiveExecutionRegion(ShellSyntaxNode node) => node switch { - SimpleCommandSyntax simple => IsPipelineCallback(simple), - ShellBlockSyntax block => BlockContains(block, ContainsPipelineCallback), - GroupSyntax group => ContainsPipelineCallback(group.Body), - CommandListSyntax list => ListContains(list, ContainsPipelineCallback), - PipelineSyntax pipeline => PipelineContains(pipeline, ContainsPipelineCallback), + SimpleCommandSyntax simple => IsPipelineSensitiveExecutionRegionHost(simple), + ShellBlockSyntax block => BlockContains( + block, + ContainsPipelineSensitiveExecutionRegion), + GroupSyntax group => ContainsPipelineSensitiveExecutionRegion(group.Body), + CommandListSyntax list => ListContains( + list, + ContainsPipelineSensitiveExecutionRegion), + PipelineSyntax pipeline => PipelineContains( + pipeline, + ContainsPipelineSensitiveExecutionRegion), + ExecutionRegionSyntax => true, _ => false, }; @@ -1690,14 +1752,17 @@ private static bool MayMutatePipelineState(ShellSyntaxNode node) => _ => false, }; - private static bool IsPipelineCallback(SimpleCommandSyntax simple) + private static bool IsPipelineSensitiveExecutionRegionHost(SimpleCommandSyntax simple) { var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, commandIdentityProven: true); return binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && binding.ParameterSet is PwshExecutionRegionParameterSet.ForEachScriptBlock or - PwshExecutionRegionParameterSet.WhereScriptBlock; + PwshExecutionRegionParameterSet.WhereScriptBlock or + PwshExecutionRegionParameterSet.InvokeInProcess or + PwshExecutionRegionParameterSet.MeasureExpression or + PwshExecutionRegionParameterSet.TraceExpression; } private static bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) @@ -1818,19 +1883,19 @@ private PwshFlowResult AnalyzeExecutionRegion( } return new PwshFlowResult( - RestoreDirectCallExit( + RestoreChildScopeExit( body.OnSuccess, input, locationMutated, childScopeMayEscape), - RestoreDirectCallExit( + RestoreChildScopeExit( body.OnFailure, input, locationMutated, childScopeMayEscape)); } - private static AnalysisContext? RestoreDirectCallExit( + private static AnalysisContext? RestoreChildScopeExit( AnalysisContext? bodyExit, AnalysisContext input, bool locationMutated, diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs index 72b4c00..a6e456b 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -92,6 +92,8 @@ internal sealed record PwshExecutionRegionBindingResult internal bool HasExplicitInputObject { get; init; } + internal bool HasNoNewScope { get; init; } + internal IReadOnlyList Bindings { get; init; } = Array.Empty(); } @@ -443,6 +445,8 @@ private static PwshExecutionRegionBindingResult BindReceiver( ParameterSet = parameterSet, CanonicalCommandName = canonicalName, HasExplicitInputObject = arguments.HasNamed("InputObject"), + HasNoNewScope = receiver == PwshExecutionRegionReceiver.InvokeCommand && + arguments.IsSwitchEnabled("NoNewScope"), Bindings = bindings.OrderBy(binding => binding.HostClauseElementIndex).ToArray(), }; } @@ -1572,6 +1576,33 @@ internal void AddNamedParameter(string name) internal bool HasNamed(string name) => NamedParameters.Contains(name); + internal bool IsSwitchEnabled(string name) + { + if (!HasNamed(name)) + { + return false; + } + + foreach (var argument in NamedArguments) + { + if (!string.Equals( + argument.ParameterName, + name, + StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + return string.Equals( + argument.Value, + "$true", + StringComparison.OrdinalIgnoreCase) || + argument.Value == "1"; + } + + return true; + } + internal bool HasAnyNamed(params string[] names) => names.Any(HasNamed); internal int CountNamed(params string[] names) => names.Count(HasNamed); diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index f08cf49..8df94d5 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -297,6 +297,20 @@ public void Invoke_command_distinguishes_in_process_and_remote_parameter_sets() Assert.Equal(PwshExecutionRegionParameterSet.InvokeInProcess, local.ParameterSet); Assert.True(Assert.Single(local.Bindings).IsComplete); + Assert.True(local.HasNoNewScope); + var positionalLocal = Bind("Invoke-Command { Get-Date }"); + Assert.Equal( + PwshExecutionRegionParameterSet.InvokeInProcess, + positionalLocal.ParameterSet); + Assert.False(positionalLocal.HasNoNewScope); + Assert.False(Bind( + "Invoke-Command -NoNewScope:$false { Get-Date }").HasNoNewScope); + Assert.True(Bind( + "Invoke-Command -NoNewScope:$true { Get-Date }").HasNoNewScope); + Assert.False(Bind( + "Invoke-Command -NoNewScope:0 { Get-Date }").HasNoNewScope); + Assert.True(Bind( + "Invoke-Command -NoNewScope:1 { Get-Date }").HasNoNewScope); Assert.Equal(PwshExecutionRegionParameterSet.InvokeRemote, remote.ParameterSet); var remoteBinding = Assert.Single(remote.Bindings); Assert.Equal(ExecutionRegionTiming.Unknown, remoteBinding.Timing); diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 9d70788..877389f 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -35,6 +35,150 @@ public void Current_scope_once_receivers_publish_typed_regions( Assert.All(result.Commands, command => Assert.True(command.IsComplete)); } + [Theory] + [InlineData("Invoke-Command { Get-Item child.txt }")] + [InlineData("icm -ScriptBlock { Get-Item child.txt }")] + [InlineData("Microsoft.PowerShell.Core\\Invoke-Command { Get-Item child.txt }")] + public void In_process_invoke_command_publishes_a_synchronous_once_region( + string source) + { + var result = ParseIsolated(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionOrigin.CommandArgument, region.Origin); + Assert.Equal(ExecutionRegionPhase.Main, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Once, region.Cardinality); + Assert.Equal(2, result.Commands.Count); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void In_process_invoke_command_isolates_bindings_but_shares_location() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; Invoke-Command { " + + "foreach ($x in 'inner') { }; Set-Location /tmp }; " + + "Write-Output $x; Get-Item child.txt"); + + var continuation = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] is "Write-Output" or "Get-Item") + .TakeLast(2) + .ToArray(); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation[0].EffectiveArguments).Value.Values); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation[1].WorkingDirectory.Kind); + Assert.All(continuation, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void In_process_invoke_command_no_new_scope_shares_supported_state() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; Invoke-Command -NoNewScope { " + + "foreach ($x in 'inner') { }; Set-Location /tmp }; " + + "Write-Output $x; Get-Item child.txt"); + + var continuation = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] is "Write-Output" or "Get-Item") + .TakeLast(2) + .ToArray(); + Assert.Equal( + new[] { "inner" }, + Assert.Single(continuation[0].EffectiveArguments).Value.Values); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation[1].WorkingDirectory.Kind); + Assert.All(continuation, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void In_process_invoke_command_explicit_false_no_new_scope_isolates_state() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "Invoke-Command -NoNewScope:$false { foreach ($x in 'inner') { } }; " + + "Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + Assert.True(continuation.IsComplete); + } + + [Theory] + [InlineData("Invoke-Command -ComputerName server -ScriptBlock { Get-Date }")] + [InlineData("Invoke-Command -AsJob -ScriptBlock { Get-Date }")] + [InlineData("Invoke-Command -NoNewScope:$scope -ScriptBlock { Get-Date }")] + public void Unproved_invoke_command_shapes_remain_unknown(string source) + { + var result = ParseIsolated(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.Equal(ExecutionRegionTiming.Unknown, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Unknown, region.Cardinality); + Assert.All(result.Commands, command => Assert.False(command.IsComplete)); + } + + [Fact] + public void In_process_invoke_command_child_scope_isolates_alias_mutation() + { + var result = ParseIsolated( + "Invoke-Command { Set-Alias Measure-Command Write-Output }; " + + "Measure-Command { Get-Date }"); + + var host = result.Syntax.Statements + .OfType() + .SelectMany(list => list.Items) + .Select(item => item.Command) + .OfType() + .Last(); + Assert.Equal( + ExecutionRegionPhase.Main, + Assert.Single(host.ExecutionRegions).Phase); + Assert.True(result.Commands.Last().IsComplete); + } + + [Fact] + public void In_process_invoke_command_no_new_scope_propagates_alias_mutation() + { + var result = ParseIsolated( + "Invoke-Command -NoNewScope { Set-Alias Measure-Command Write-Output }; " + + "Measure-Command { Get-Date }"); + + var host = result.Syntax.Statements + .OfType() + .SelectMany(list => list.Items) + .Select(item => item.Command) + .OfType() + .Last(); + Assert.Equal( + ExecutionRegionPhase.Unknown, + Assert.Single(host.ExecutionRegions).Phase); + Assert.False(result.Commands.Last().IsComplete); + } + + [Fact] + public void In_process_invoke_command_joins_body_outcomes_before_host_continuation() + { + var result = ParseIsolated( + "Invoke-Command { Set-Location /maybe } && Get-Item child.txt"); + + var continuation = result.Commands.Last(); + Assert.Equal("Get-Item", continuation.Clause.Verb.Tokens[0]); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation.WorkingDirectory.Kind); + Assert.True(continuation.IsComplete); + } + [Fact] public void Current_scope_once_receiver_propagates_binding_state() { @@ -440,6 +584,37 @@ public void Interleaved_common_parameter_writer_invalidates_callback_state() Assert.Single(callbackWrite.EffectiveArguments).Value.Kind); } + [Theory] + [InlineData("Invoke-Command", "")] + [InlineData("Invoke-Command -NoNewScope", "")] + [InlineData(".", "")] + [InlineData("&", "")] + [InlineData("Measure-Command", "")] + [InlineData("Trace-Command -Name ParameterBinding -Expression", " -PSHost")] + public void Pipeline_stage_effects_make_synchronous_region_state_unknown( + string invocation, + string trailingArguments) + { + var result = ParseIsolated( + "foreach ($x in 'start') { }; " + invocation + " { " + + "Write-Output $x; Write-Output $x; foreach ($x in 'end') { } }" + + trailingArguments + " | " + + "Write-Output -OutVariable x"); + + var upstream = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .Take(2) + .ToArray(); + Assert.Equal(2, upstream.Length); + Assert.All(upstream, command => + { + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + }); + } + [Fact] public void Unknown_region_poisons_later_variable_facts() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index ff5a6b0..dc25ef3 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -1082,6 +1082,78 @@ public void PowerShell_pipeline_callbacks_share_state_and_use_semantic_phases() Lines(output)); } + [Fact] + public void PowerShell_synchronous_regions_observe_scope_and_pipeline_stage_effects() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$start=(Get-Location).Path; " + + "$temp=[IO.Path]::TrimEndingDirectorySeparator(" + + "(Resolve-Path ([IO.Path]::GetTempPath())).Path); " + + "$root=[IO.Path]::TrimEndingDirectorySeparator(" + + "[IO.Path]::GetPathRoot($start)); " + + "$target=if($temp -ne $start){$temp}else{$root}; " + + "if(!$target -or $target -eq $start -or " + + "!(Test-Path -LiteralPath $target -PathType Container)){throw 'target'}; " + + "\"target-distinct=<$($target -ne $start)>\"; " + + "$x='outer'; Invoke-Command { " + + "$x='inner'; Set-Location $target; Set-Alias zz Get-Date }; " + + "\"default-x=<$x>\"; " + + "\"default-cwd-target=<$((Get-Location).Path -eq $target)>\"; " + + "\"default-alias=<$([bool](Get-Alias zz -ErrorAction Ignore))>\"; " + + "$x='outer'; Invoke-Command -NoNewScope:$false { $x='false-inner' }; " + + "\"false-x=<$x>\"; Set-Location $start; " + + "Invoke-Command -NoNewScope:$true { " + + "$x='shared'; Set-Location $target; Set-Alias zz Get-Date }; " + + "\"shared-x=<$x>\"; " + + "\"shared-cwd-target=<$((Get-Location).Path -eq $target)>\"; " + + "\"shared-alias=<$([bool](Get-Alias zz -ErrorAction Ignore))>\"; " + + "Set-Location $start; " + + "$missing=Join-Path $start ('missing-'+[guid]::NewGuid().ToString('N')); " + + "if(Test-Path -LiteralPath $missing){throw 'missing'}; " + + "Invoke-Command { Set-Location $missing " + + "-ErrorAction SilentlyContinue }; " + + "\"failure-status=<$?>\"; " + + "\"failure-cwd-start=<$((Get-Location).Path -eq $start)>\"; " + + "$x='start'; Invoke-Command -NoNewScope { " + + "\"invoke-interleave=<$x>\"; \"invoke-interleave=<$x>\" } | " + + "Write-Output -OutVariable x | Out-Null; $x; " + + "$x='start'; Measure-Command { " + + "Write-Host \"measure-stage=<$x>\" } | " + + "Write-Output -OutVariable x | Out-Null; " + + "$x='start'; Trace-Command -Name ParameterBinding -Expression { " + + "\"trace-stage=<$x>\" } -PSHost 5>$null | " + + "Write-Output -OutVariable x | Out-Null; $x"); + + Assert.Equal( + new[] + { + "target-distinct=", + "default-x=", + "default-cwd-target=", + "default-alias=", + "false-x=", + "shared-x=", + "shared-cwd-target=", + "shared-alias=", + "failure-status=", + "failure-cwd-start=", + "invoke-interleave=<>", + "invoke-interleave=>", + "measure-stage=<>", + "trace-stage=<>", + }, + Lines(output)); + } + private static bool IsAvailable(string executable) { try