diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8110432..056f93e 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -393,9 +393,17 @@ priorities. synchronous/once, execute against current-scope state, downgrade across conflicting loop visits, and are pinned by live PowerShell probes for current-scope, opaque-data, shadowed-command, and module-qualified - behavior. Ordinary assignment state transfer remains an atomic task 7.5b - follow-up; task 7.5b is not complete. Continue with `ForEach-Object`, - `Where-Object`, and in-process `Invoke-Command`; child process/runspace jobs and parallel + behavior. `ForEach-Object` Begin, Process, RemainingScripts, and End and + `Where-Object` FilterScript now retain authored region order while the + analyzer applies semantic phase order. Standalone, first-pipeline-stage, + upstream-pipeline, and explicit-InputObject cardinalities remain + distinct; zero-or-more Process/Filter effects use a conservative fixed + point, and Begin/End state surrounds that join. Live PowerShell probes + 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 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 6b8334e..0928251 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -987,6 +987,8 @@ private readonly Dictionary> private long _nonRegionStateMutationCount; private long _locationStateMutationCount; private long _childScopeEscapeRiskCount; + private bool _pipelineStageMayReceiveInput; + private bool _pipelineCallbacksMayInterleave; private PwshForEachValueAnalyzer( PwshParserOptions options, @@ -1184,7 +1186,7 @@ private PwshFlowResult ApplyExecutionRegionEffect( var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, IsCommandIdentityProven(simple.Clause, receiverInput)); - if (!IsCurrentScopeOnceReceiver(binding) || + if (!IsSupportedSynchronousReceiver(binding) || !TryApplyExecutionRegionBindings(simple, binding, out var regions)) { RecordExecutionRegions( @@ -1208,9 +1210,19 @@ flow.OnFailure is AnalysisContext failure return flow; } + if (binding.ParameterSet is PwshExecutionRegionParameterSet.ForEachScriptBlock or + PwshExecutionRegionParameterSet.WhereScriptBlock) + { + return AnalyzePipelineCallbackRegions( + binding, + regions, + regionInput, + flow); + } + var executionRegionEffectCount = _executionRegionEffectCount; var nonRegionStateMutationCount = _nonRegionStateMutationCount; - var bodyFlow = AnalyzeBlock(regions[0].Body, regionInput); + var bodyFlow = AnalyzeExecutionRegionBody(regions[0].Body, regionInput); _executionRegionEffectCount = executionRegionEffectCount + 1; _nonRegionStateMutationCount = nonRegionStateMutationCount; return bodyFlow.JoinedState is AnalysisContext bodyExit @@ -1218,6 +1230,162 @@ flow.OnFailure is AnalysisContext failure : flow; } + private PwshFlowResult AnalyzePipelineCallbackRegions( + PwshExecutionRegionBindingResult binding, + IReadOnlyList regions, + AnalysisContext input, + PwshFlowResult fallback) + { + var parameterSet = binding.ParameterSet; + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var current = input; + if (parameterSet == PwshExecutionRegionParameterSet.ForEachScriptBlock && + !TryAnalyzeRegionPhase(regions, ExecutionRegionPhase.Begin, current, out current)) + { + return fallback; + } + + var processRegions = RegionsForPhase( + regions, + parameterSet == PwshExecutionRegionParameterSet.WhereScriptBlock + ? ExecutionRegionPhase.Filter + : ExecutionRegionPhase.Process); + var mayReceiveInput = _pipelineStageMayReceiveInput || + binding.HasExplicitInputObject; + if (parameterSet == PwshExecutionRegionParameterSet.WhereScriptBlock && + !mayReceiveInput) + { + AnalyzeUnreachableRegions(processRegions, current); + } + else if (mayReceiveInput) + { + if (!TryAnalyzeZeroOrMoreRegions(processRegions, current, out current)) + { + return fallback; + } + } + else if (!TryAnalyzeRegionSequence(processRegions, current, out current)) + { + return fallback; + } + + if (parameterSet == PwshExecutionRegionParameterSet.ForEachScriptBlock && + !TryAnalyzeRegionPhase(regions, ExecutionRegionPhase.End, current, out current)) + { + return fallback; + } + + _executionRegionEffectCount = executionRegionEffectCount + regions.Count; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + return PwshFlowResult.Both(current); + } + + private bool TryAnalyzeRegionPhase( + IReadOnlyList regions, + ExecutionRegionPhase phase, + AnalysisContext input, + out AnalysisContext output) => + TryAnalyzeRegionSequence(RegionsForPhase(regions, phase), input, out output); + + private bool TryAnalyzeRegionSequence( + IReadOnlyList regions, + AnalysisContext input, + out AnalysisContext output) + { + output = input; + for (var index = 0; index < regions.Count; index++) + { + var body = AnalyzeExecutionRegionBody(regions[index].Body, output); + if (body.JoinedState is not AnalysisContext bodyExit) + { + return false; + } + + output = bodyExit; + } + + return true; + } + + private bool TryAnalyzeZeroOrMoreRegions( + IReadOnlyList regions, + AnalysisContext input, + out AnalysisContext output) + { + var exits = input; + var head = input; + for (var iteration = 0; + iteration <= ShellAnalysisLimits.MaxValueCandidates; + iteration++) + { + if (!TryConsumeLoopAnalysisTransition() || + !TryAnalyzeRegionSequence(regions, head, out var bodyExit)) + { + output = input; + return false; + } + + exits = AnalysisContext.Join(exits, bodyExit); + var nextHead = AnalysisContext.Join(head, bodyExit); + if (head.StateEquals(nextHead)) + { + output = exits; + return true; + } + + head = nextHead; + } + + output = AnalysisContext.Widen(input, exits); + return true; + } + + private void AnalyzeUnreachableRegions( + IReadOnlyList regions, + AnalysisContext input) + { + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var locationStateMutationCount = _locationStateMutationCount; + var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; + TryAnalyzeRegionSequence(regions, input, out _); + _executionRegionEffectCount = executionRegionEffectCount; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + _locationStateMutationCount = locationStateMutationCount; + _childScopeEscapeRiskCount = childScopeEscapeRiskCount; + } + + private PwshFlowResult AnalyzeExecutionRegionBody( + ShellBlockSyntax body, + AnalysisContext input) + { + var enclosingStageMayReceiveInput = _pipelineStageMayReceiveInput; + _pipelineStageMayReceiveInput = false; + var bodyInput = _pipelineCallbacksMayInterleave + ? input.Invalidate(unknownCwd: true) + : input; + var flow = AnalyzeBlock(body, bodyInput); + _pipelineStageMayReceiveInput = enclosingStageMayReceiveInput; + return flow; + } + + private static IReadOnlyList RegionsForPhase( + IReadOnlyList regions, + ExecutionRegionPhase phase) + { + var matching = new List(); + for (var index = 0; index < regions.Count; index++) + { + if (regions[index].Phase == phase) + { + matching.Add(regions[index]); + } + } + + return matching; + } + private static bool IsCommandIdentityProven( Clause clause, AnalysisContext input) @@ -1272,15 +1440,34 @@ private static bool HaveSameCompleteRegionFacts( return true; } - private static bool IsCurrentScopeOnceReceiver( + private static bool IsSupportedSynchronousReceiver( PwshExecutionRegionBindingResult binding) => binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && - binding.ParameterSet is PwshExecutionRegionParameterSet.MeasureExpression or - PwshExecutionRegionParameterSet.TraceExpression && - binding.Bindings.Count == 1 && - binding.Bindings[0].Timing == ExecutionRegionTiming.Synchronous && - binding.Bindings[0].Cardinality == ExecutionRegionCardinality.Once && - binding.Bindings[0].IsComplete; + (binding.ParameterSet is PwshExecutionRegionParameterSet.MeasureExpression or + PwshExecutionRegionParameterSet.TraceExpression or + PwshExecutionRegionParameterSet.ForEachScriptBlock or + PwshExecutionRegionParameterSet.WhereScriptBlock) && + AllBindingsAreCompleteAndSynchronous(binding.Bindings); + + private static bool AllBindingsAreCompleteAndSynchronous( + IReadOnlyList bindings) + { + if (bindings.Count == 0) + { + return false; + } + + for (var index = 0; index < bindings.Count; index++) + { + if (!bindings[index].IsComplete || + bindings[index].Timing != ExecutionRegionTiming.Synchronous) + { + return false; + } + } + + return true; + } private static bool TryApplyExecutionRegionBindings( SimpleCommandSyntax simple, @@ -1419,33 +1606,158 @@ private PwshFlowResult AnalyzeList(CommandListSyntax list, AnalysisContext input private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input) { var stageInput = input; - foreach (var stage in pipeline.Stages) + var enclosingCallbacksMayInterleave = _pipelineCallbacksMayInterleave; + _pipelineCallbacksMayInterleave |= PipelineCallbacksMayInterleave(pipeline); + try + { + for (var stageIndex = 0; stageIndex < pipeline.Stages.Count; stageIndex++) + { + var stage = pipeline.Stages[stageIndex]; + var executionRegionEffectsBefore = _executionRegionEffectCount; + var nonRegionMutationsBefore = _nonRegionStateMutationCount; + var enclosingStageMayReceiveInput = _pipelineStageMayReceiveInput; + _pipelineStageMayReceiveInput = stageIndex > 0; + var stageFlow = AnalyzeNode(stage, stageInput); + _pipelineStageMayReceiveInput = enclosingStageMayReceiveInput; + if (stageFlow.JoinedState is not AnalysisContext stageExit) + { + return new PwshFlowResult(null, null); + } + + if (!stageInput.StateEquals(stageExit)) + { + var regionCausedTransition = + _executionRegionEffectCount > executionRegionEffectsBefore; + var unrelatedMutationCausedTransition = + _nonRegionStateMutationCount > nonRegionMutationsBefore; + if (!regionCausedTransition || unrelatedMutationCausedTransition) + { + _isComplete = false; + return new PwshFlowResult(null, null); + } + + stageInput = stageInput.Invalidate(unknownCwd: true); + } + } + + return PwshFlowResult.Both(stageInput); + } + finally + { + _pipelineCallbacksMayInterleave = enclosingCallbacksMayInterleave; + } + } + + private static bool PipelineCallbacksMayInterleave(PipelineSyntax pipeline) + { + var hasCallback = false; + var statefulStageCount = 0; + for (var index = 0; index < pipeline.Stages.Count; index++) { - var executionRegionEffectsBefore = _executionRegionEffectCount; - var nonRegionMutationsBefore = _nonRegionStateMutationCount; - var stageFlow = AnalyzeNode(stage, stageInput); - if (stageFlow.JoinedState is not AnalysisContext stageExit) + var stage = pipeline.Stages[index]; + hasCallback |= ContainsPipelineCallback(stage); + if (MayMutatePipelineState(stage)) { - return new PwshFlowResult(null, null); + statefulStageCount++; } + } + + return hasCallback && statefulStageCount > 1; + } - if (!stageInput.StateEquals(stageExit)) + private static bool ContainsPipelineCallback(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), + _ => false, + }; + + private static bool MayMutatePipelineState(ShellSyntaxNode node) => + node switch + { + SimpleCommandSyntax simple => SimpleMayMutatePipelineState(simple), + ShellBlockSyntax block => BlockContains(block, MayMutatePipelineState), + GroupSyntax group => MayMutatePipelineState(group.Body), + CommandListSyntax list => ListContains(list, MayMutatePipelineState), + PipelineSyntax pipeline => PipelineContains(pipeline, MayMutatePipelineState), + ForEachSyntax => true, + CommandSubstitutionSyntax => true, + ExecutionRegionSyntax => true, + _ => false, + }; + + private static bool IsPipelineCallback(SimpleCommandSyntax simple) + { + var binding = PwshExecutionRegionBindingCatalog.Bind( + simple.Clause, + commandIdentityProven: true); + return binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && + binding.ParameterSet is PwshExecutionRegionParameterSet.ForEachScriptBlock or + PwshExecutionRegionParameterSet.WhereScriptBlock; + } + + private static bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) + { + if (simple.Substitutions.Count > 0 || + simple.ExecutionRegions.Count > 0 || + simple.Clause.Verb.IsDynamic) + { + return true; + } + + return PwshPersistentStateMutation.TryGetEffect( + simple.Clause, + Array.Empty(), + out _); + } + + private static bool BlockContains( + ShellBlockSyntax block, + Func predicate) + { + for (var index = 0; index < block.Statements.Count; index++) + { + if (predicate(block.Statements[index])) { - var regionCausedTransition = - _executionRegionEffectCount > executionRegionEffectsBefore; - var unrelatedMutationCausedTransition = - _nonRegionStateMutationCount > nonRegionMutationsBefore; - if (!regionCausedTransition || unrelatedMutationCausedTransition) - { - _isComplete = false; - return new PwshFlowResult(null, null); - } + return true; + } + } + + return false; + } + + private static bool ListContains( + CommandListSyntax list, + Func predicate) + { + for (var index = 0; index < list.Items.Count; index++) + { + if (predicate(list.Items[index].Command)) + { + return true; + } + } + + return false; + } - stageInput = stageInput.Invalidate(unknownCwd: true); + private static bool PipelineContains( + PipelineSyntax pipeline, + Func predicate) + { + for (var index = 0; index < pipeline.Stages.Count; index++) + { + if (predicate(pipeline.Stages[index])) + { + return true; } } - return PwshFlowResult.Both(stageInput); + return false; } private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) @@ -1486,7 +1798,7 @@ private PwshFlowResult AnalyzeExecutionRegion( var nonRegionStateMutationCount = _nonRegionStateMutationCount; var locationStateMutationCount = _locationStateMutationCount; var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; - var body = AnalyzeBlock(region.Body, input); + var body = AnalyzeExecutionRegionBody(region.Body, input); var locationMutated = _locationStateMutationCount > locationStateMutationCount; var childScopeMayEscape = _childScopeEscapeRiskCount > childScopeEscapeRiskCount; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs index 345a026..72b4c00 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -90,6 +90,8 @@ internal sealed record PwshExecutionRegionBindingResult internal string? CanonicalCommandName { get; init; } + internal bool HasExplicitInputObject { get; init; } + internal IReadOnlyList Bindings { get; init; } = Array.Empty(); } @@ -440,6 +442,7 @@ private static PwshExecutionRegionBindingResult BindReceiver( Receiver = receiver, ParameterSet = parameterSet, CanonicalCommandName = canonicalName, + HasExplicitInputObject = arguments.HasNamed("InputObject"), Bindings = bindings.OrderBy(binding => binding.HostClauseElementIndex).ToArray(), }; } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index 52175b9..f08cf49 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -105,8 +105,8 @@ public void Module_qualified_receiver_is_admitted_once_body_emission_is_guarante Assert.Equal(2, parsed.Clauses.Count); var host = Assert.IsType(Assert.Single(parsed.Syntax.Statements)); var region = Assert.Single(host.ExecutionRegions); - Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); - Assert.All(parsed.Commands, command => Assert.False(command.IsComplete)); + Assert.Equal(ExecutionRegionPhase.Process, region.Phase); + Assert.All(parsed.Commands, command => Assert.True(command.IsComplete)); } [Theory] @@ -131,6 +131,18 @@ public void Common_parameter_aliases_preserve_the_positional_process_block(strin Assert.Equal(ExecutionRegionPhase.Process, binding.Phase); } + [Theory] + [InlineData("Where-Object -InputObject value -FilterScript { Get-Date }")] + [InlineData("ForEach-Object -Inp value -Process { Get-Date }")] + public void Explicit_input_object_binding_is_retained_for_cardinality_analysis( + string source) + { + var result = Bind(source); + + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + Assert.True(result.HasExplicitInputObject); + } + [Fact] public void Parameter_alias_prefix_can_bind_the_script_block_parameter() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index c3bbf73..9d70788 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -131,9 +131,9 @@ public void Module_qualified_receiver_remains_proved_after_alias_mutation() } [Fact] - public void Command_script_block_is_exposed_as_an_unknown_incomplete_region() + public void Unknown_command_script_block_is_exposed_as_an_incomplete_region() { - const string source = "ForEach-Object { Remove-Item victim.txt }"; + const string source = "Invoke-Custom { Remove-Item victim.txt }"; var result = Parse(source); @@ -149,7 +149,7 @@ public void Command_script_block_is_exposed_as_an_unknown_incomplete_region() var body = Assert.IsType(Assert.Single(region.Body.Statements)); Assert.Equal("Remove-Item", Assert.Single(body.Clause.Verb.Tokens)); - Assert.Equal(new[] { "ForEach-Object", "Remove-Item" }, + Assert.Equal(new[] { "Invoke-Custom", "Remove-Item" }, result.Commands.Select(command => command.Clause.Verb.Tokens[0])); Assert.All(result.Commands, command => Assert.False(command.IsComplete)); Assert.Equal(CommandOccurrenceRole.ExecutionRegion, result.Commands[1].ImmediateRole); @@ -175,9 +175,25 @@ public void Multiple_script_blocks_keep_authored_regions_and_host_coordinates() .Select(region => region.SourceStart) .SequenceEqual(host.ExecutionRegions.Select(region => region.SourceStart) .OrderBy(start => start))); + Assert.Equal( + new[] + { + ExecutionRegionPhase.Begin, + ExecutionRegionPhase.Process, + ExecutionRegionPhase.End, + }, + host.ExecutionRegions.Select(region => region.Phase)); + Assert.Equal( + new[] + { + ExecutionRegionCardinality.Once, + ExecutionRegionCardinality.OncePerInputObject, + ExecutionRegionCardinality.Once, + }, + host.ExecutionRegions.Select(region => region.Cardinality)); Assert.All(host.ExecutionRegions, region => { - Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); Assert.Single(region.Body.Statements); }); Assert.Equal(4, result.Commands.Count); @@ -206,7 +222,7 @@ public void Pure_output_expressions_do_not_invent_command_occurrences(string sou var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); Assert.Empty(Assert.Single(host.ExecutionRegions).Body.Statements); Assert.Single(result.Commands); - Assert.False(result.Commands[0].IsComplete); + Assert.True(result.Commands[0].IsComplete); } [Theory] @@ -248,11 +264,182 @@ public void Unknown_region_poisons_later_cwd_and_command_resolution_facts() location.Commands.Last().WorkingDirectory.Kind); Assert.False(location.Commands.Last().IsComplete); Assert.Equal( - ShellValueDomainKind.Unknown, + ShellValueDomainKind.Exact, alias.Commands.Last().WorkingDirectory.Kind); Assert.False(alias.Commands.Last().IsComplete); } + [Fact] + public void Standalone_for_each_object_uses_semantic_phase_order() + { + var result = ParseIsolated( + "ForEach-Object " + + "-End { Write-Output $x } " + + "-Begin { foreach ($x in 'begin') { } } " + + "-Process { Write-Output $x; foreach ($x in 'process') { } }; " + + "Write-Output $x"); + + var writes = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ToArray(); + Assert.Equal(3, writes.Length); + Assert.Equal("process", Assert.Single(writes[0].EffectiveArguments).Value.Values[0]); + Assert.Equal("begin", Assert.Single(writes[1].EffectiveArguments).Value.Values[0]); + Assert.Equal("process", Assert.Single(writes[2].EffectiveArguments).Value.Values[0]); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void Pipeline_for_each_object_joins_zero_and_repeated_process_visits() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; Write-Output input | ForEach-Object " + + "-End { Write-Output $x } " + + "-Begin { foreach ($x in 'begin') { } } " + + "-Process { Write-Output $x; foreach ($x in 'process') { } }; " + + "Write-Output $x"); + + var writes = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ToArray(); + Assert.Equal(4, writes.Length); + Assert.Equal( + new[] { "begin", "process" }, + Assert.Single(writes[1].EffectiveArguments).Value.Values + .OrderBy(value => value)); + Assert.Equal( + new[] { "begin", "process" }, + Assert.Single(writes[2].EffectiveArguments).Value.Values + .OrderBy(value => value)); + Assert.False(writes[3].IsComplete); + } + + [Fact] + public void Standalone_where_object_records_but_does_not_apply_filter_state() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "Where-Object { foreach ($x in 'filter') { } }; Write-Output $x"); + + var host = result.Syntax.Statements + .OfType() + .SelectMany(list => list.Items) + .Select(item => item.Command) + .OfType() + .Single(command => command.Clause.Verb.Tokens[0] == "Where-Object"); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Filter, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); + Assert.Equal(ExecutionRegionCardinality.OncePerInputObject, region.Cardinality); + var continuation = result.Commands.Last(); + Assert.Equal("outer", Assert.Single(continuation.EffectiveArguments).Value.Values[0]); + Assert.True(continuation.IsComplete); + } + + [Fact] + public void Explicit_where_input_conservatively_applies_filter_state() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "Where-Object -InputObject value -FilterScript { " + + "foreach ($x in 'filter') { } }; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.Equal( + new[] { "filter", "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values + .OrderBy(value => value)); + Assert.True(continuation.IsComplete); + } + + [Fact] + public void First_pipeline_stage_for_each_processes_once_without_upstream_input() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "ForEach-Object { Write-Output $x; foreach ($x in 'process') { } } | " + + "Out-Null"); + + var processWrite = result.Commands + .Single(command => command.Clause.Verb.Tokens[0] == "Write-Output"); + Assert.Equal( + new[] { "outer" }, + Assert.Single(processWrite.EffectiveArguments).Value.Values); + } + + [Fact] + public void First_pipeline_stage_where_has_no_filter_input() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "Where-Object { foreach ($x in 'filter') { } } | Out-Null; " + + "Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + Assert.True(continuation.IsComplete); + } + + [Fact] + public void Interleaved_pipeline_callbacks_never_publish_stale_upstream_state() + { + var result = ParseIsolated( + "foreach ($x in 'start') { }; Write-Output 1 2 | " + + "ForEach-Object { Write-Output $x } | " + + "ForEach-Object { foreach ($x in 'down') { }; Write-Output $_ }"); + + var upstream = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ElementAt(1); + Assert.False(upstream.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(upstream.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData(". { Write-Output $x; Write-Output $x }")] + [InlineData("& { Write-Output $x; Write-Output $x }")] + public void Interleaved_direct_regions_never_publish_stale_upstream_state( + string upstreamStage) + { + var result = ParseIsolated( + "foreach ($x in 'start') { }; " + upstreamStage + " | " + + "ForEach-Object { foreach ($x in 'down') { }; Write-Output $_ }"); + + 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 Interleaved_common_parameter_writer_invalidates_callback_state() + { + var result = ParseIsolated( + "foreach ($x in 'start') { }; Write-Output 1 2 | " + + "ForEach-Object { Write-Output $x } | " + + "Write-Output -OutVariable x"); + + var callbackWrite = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ElementAt(1); + Assert.False(callbackWrite.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(callbackWrite.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 d6cf2c3..ff5a6b0 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -1020,6 +1020,68 @@ public void PowerShell_current_scope_receivers_and_data_blocks_have_distinct_sta Lines(output)); } + [Fact] + public void PowerShell_pipeline_callbacks_share_state_and_use_semantic_phases() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$x='outer'; 1,2 | ForEach-Object " + + "-End { \"end-before=<$x>\"; $x='end' } " + + "-Begin { \"begin-before=<$x>\"; $x='begin' } " + + "-Process { \"process-$_-before=<$x>\"; $x=\"p$_\" }; " + + "\"foreach-after=<$x>\"; " + + "$x='outer'; @() | ForEach-Object -Begin { $x='empty-begin' } " + + "-Process { $x='empty-process' } " + + "-End { \"empty-end-before=<$x>\"; $x='empty-end' }; " + + "\"empty-after=<$x>\"; " + + "$x='outer'; 1,2 | Where-Object { $x=\"w$_\"; $true } | Out-Null; " + + "\"where-after=<$x>\"; " + + "$x='outer'; ForEach-Object { $x='standalone-process' }; " + + "\"standalone-foreach=<$x>\"; " + + "$x='outer'; Where-Object { $x='standalone-filter'; $true }; " + + "\"standalone-where=<$x>\"; " + + "$x='outer'; Where-Object -InputObject value " + + "-FilterScript { $x='explicit-filter'; $true } | Out-Null; " + + "\"explicit-where=<$x>\"; " + + "$x='start'; 1,2 | ForEach-Object { \"interleave=<$x>\" } | " + + "ForEach-Object { $x='down'; $_ }; " + + "$x='start'; . { \"dot=<$x>\"; \"dot=<$x>\" } | " + + "ForEach-Object { $x='down'; $_ }; " + + "$x='start'; & { \"call=<$x>\"; \"call=<$x>\" } | " + + "ForEach-Object { $x='down'; $_ }"); + + Assert.Equal( + new[] + { + "begin-before=", + "process-1-before=", + "process-2-before=", + "end-before=", + "foreach-after=", + "empty-end-before=", + "empty-after=", + "where-after=", + "standalone-foreach=", + "standalone-where=", + "explicit-where=", + "interleave=", + "interleave=", + "dot=", + "dot=", + "call=", + "call=", + }, + Lines(output)); + } + private static bool IsAvailable(string executable) { try