From 8edc771db744509540fbc5ab8a394e9bcbd04ee7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 23:42:06 +0000 Subject: [PATCH] Analyze synchronous PowerShell command regions --- IMPLEMENTATION_PLAN.md | 19 +- .../Pwsh/Parsing/PwshCommandParser.cs | 5 + .../Pwsh/Parsing/PwshForEachValueAnalysis.cs | 172 ++++++++++++++++-- .../PwshExecutionRegionBindingCatalogTests.cs | 12 +- .../PwshExecutionRegionStructuralTests.cs | 133 ++++++++++++++ .../Parsing/ShellValueOracleTests.cs | 39 ++++ 6 files changed, 356 insertions(+), 24 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 1a44efd..8110432 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -378,17 +378,24 @@ priorities. incomplete unless its separate module-baseline proof is supplied. Local `Invoke-Command -AsJob`, ambiguous prefixes, malformed value binding, unproved identities, and unknown receivers retain unknown/incomplete - facts. Module-qualified identities are catalogued, but the parser keeps - rejecting those forms atomically until the region-emission slice can - expose every body command. The first direct-operator sub-slice now handles + facts. Supported catalog-owned module qualifications now pass structural + admission because every possible body remains visible; the occurrence + analyzer still withholds typed receiver facts after an observed command- + resolution mutation unless the authored module qualification proves the + identity independently. The first direct-operator sub-slice now handles currently supported command interiors in typed synchronous `& {}` and `. {}` regions without synthetic host commands. It isolates ordinary direct-call binding and command-resolution exit mutation, invalidates explicitly escaping scope/provider mutation, carries shared location outcomes, and keeps block arguments and leading `param()` declarations - atomic. Ordinary assignment state transfer remains an atomic task 7.5b - follow-up; task 7.5b is not complete. Continue with synchronous current-runspace callbacks; - child process/runspace jobs and parallel + atomic. `Measure-Command -Expression` and `Trace-Command -Expression` + now form the first command-owned vertical slice: their Main regions are + 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 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/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index d8c51f0..6043677 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -263,6 +263,11 @@ private static bool IsUnsupportedModuleQualifiedCmdlet(string command) return false; } + if (PwshExecutionRegionBindingCatalog.IsSupportedModuleQualifiedCommand(command)) + { + return false; + } + var separator = command.LastIndexOf('\\'); if (separator <= 0 || separator + 1 >= command.Length) { diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 9a440ab..6b8334e 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -8,6 +8,7 @@ using System.Runtime.CompilerServices; using System.Text; using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Pwsh.Verbs; using ShellSyntaxTree.Internal.Resolving; namespace ShellSyntaxTree.Internal.Pwsh.Parsing; @@ -978,6 +979,8 @@ internal sealed class PwshForEachValueAnalyzer private readonly IReadOnlyList _incompleteClauses; private readonly Dictionary _facts = new(ClauseReferenceComparer.Instance); + private readonly Dictionary> + _executionRegions = new(ClauseReferenceComparer.Instance); private bool _isComplete = true; private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; private long _executionRegionEffectCount; @@ -1124,7 +1127,7 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext } var flow = location.Value; - return ApplyExecutionRegionEffect(simple, new PwshFlowResult( + return ApplyExecutionRegionEffect(simple, current, new PwshFlowResult( flow.OnSuccess is AnalysisContext success ? success.Invalidate(locationEffectUnknownCwd) : null, @@ -1133,7 +1136,7 @@ flow.OnFailure is AnalysisContext failure : null)); } - return ApplyExecutionRegionEffect(simple, location.Value); + return ApplyExecutionRegionEffect(simple, current, location.Value); } if (simple.Clause.Verb.IsDynamic) @@ -1142,6 +1145,7 @@ flow.OnFailure is AnalysisContext failure _childScopeEscapeRiskCount++; return ApplyExecutionRegionEffect( simple, + current, PwshFlowResult.Both(current.Invalidate(unknownCwd: true))); } @@ -1160,14 +1164,16 @@ flow.OnFailure is AnalysisContext failure return ApplyExecutionRegionEffect( simple, + current, PwshFlowResult.Both(current.Invalidate(unknownCwd))); } - return ApplyExecutionRegionEffect(simple, PwshFlowResult.Both(current)); + return ApplyExecutionRegionEffect(simple, current, PwshFlowResult.Both(current)); } private PwshFlowResult ApplyExecutionRegionEffect( SimpleCommandSyntax simple, + AnalysisContext receiverInput, PwshFlowResult flow) { if (simple.ExecutionRegions.Count == 0) @@ -1175,14 +1181,147 @@ private PwshFlowResult ApplyExecutionRegionEffect( return flow; } - _executionRegionEffectCount++; - return new PwshFlowResult( - flow.OnSuccess is AnalysisContext success - ? success.Invalidate(unknownCwd: true) - : null, - flow.OnFailure is AnalysisContext failure - ? failure.Invalidate(unknownCwd: true) - : null); + var binding = PwshExecutionRegionBindingCatalog.Bind( + simple.Clause, + IsCommandIdentityProven(simple.Clause, receiverInput)); + if (!IsCurrentScopeOnceReceiver(binding) || + !TryApplyExecutionRegionBindings(simple, binding, out var regions)) + { + RecordExecutionRegions( + simple.Clause, + simple.ExecutionRegions, + simple.ExecutionRegions); + _executionRegionEffectCount++; + _childScopeEscapeRiskCount++; + return new PwshFlowResult( + flow.OnSuccess is AnalysisContext success + ? success.Invalidate(unknownCwd: true) + : null, + flow.OnFailure is AnalysisContext failure + ? failure.Invalidate(unknownCwd: true) + : null); + } + + RecordExecutionRegions(simple.Clause, regions, simple.ExecutionRegions); + if (flow.JoinedState is not AnalysisContext regionInput) + { + return flow; + } + + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var bodyFlow = AnalyzeBlock(regions[0].Body, regionInput); + _executionRegionEffectCount = executionRegionEffectCount + 1; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + return bodyFlow.JoinedState is AnalysisContext bodyExit + ? PwshFlowResult.Both(bodyExit) + : flow; + } + + private static bool IsCommandIdentityProven( + Clause clause, + AnalysisContext input) + { + if (!input.CommandResolutionInvalidated) + { + return true; + } + + return clause.Verb.Tokens.Count == 1 && + PwshExecutionRegionBindingCatalog.IsSupportedModuleQualifiedCommand( + clause.Verb.Tokens[0]); + } + + private void RecordExecutionRegions( + Clause clause, + IReadOnlyList regions, + IReadOnlyList unknownFallback) + { + if (!_executionRegions.TryGetValue(clause, out var prior)) + { + _executionRegions.Add(clause, regions); + return; + } + + if (!HaveSameCompleteRegionFacts(prior, regions)) + { + _executionRegions[clause] = unknownFallback; + } + } + + private static bool HaveSameCompleteRegionFacts( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return false; + } + + for (var index = 0; index < left.Count; index++) + { + if (left[index].HostClauseElementIndex != right[index].HostClauseElementIndex || + left[index].Phase != right[index].Phase || + left[index].Timing != right[index].Timing || + left[index].Cardinality != right[index].Cardinality) + { + return false; + } + } + + return true; + } + + private static bool IsCurrentScopeOnceReceiver( + 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; + + private static bool TryApplyExecutionRegionBindings( + SimpleCommandSyntax simple, + PwshExecutionRegionBindingResult binding, + out IReadOnlyList regions) + { + var resolved = new ExecutionRegionSyntax[binding.Bindings.Count]; + for (var bindingIndex = 0; + bindingIndex < binding.Bindings.Count; + bindingIndex++) + { + var current = binding.Bindings[bindingIndex]; + ExecutionRegionSyntax? source = null; + for (var regionIndex = 0; + regionIndex < simple.ExecutionRegions.Count; + regionIndex++) + { + if (simple.ExecutionRegions[regionIndex].HostClauseElementIndex == + current.HostClauseElementIndex) + { + source = simple.ExecutionRegions[regionIndex]; + break; + } + } + + if (source is null) + { + regions = Array.Empty(); + return false; + } + + resolved[bindingIndex] = source with + { + Phase = current.Phase, + Timing = current.Timing, + Cardinality = current.Cardinality, + }; + } + + regions = resolved; + return true; } private void RecordFacts( @@ -1202,7 +1341,7 @@ private void RecordFacts( ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, IsComplete = source.IsComplete && - !input.CommandResolutionInvalidated && + IsCommandIdentityProven(simple.Clause, input) && (!isForEachIncomplete || mayPromote), }; if (!_facts.TryGetValue(simple.Clause, out var prior)) @@ -1778,10 +1917,15 @@ private SimpleCommandSyntax RewriteSimple( facts); } - var executionRegions = new ExecutionRegionSyntax[simple.ExecutionRegions.Count]; + var sourceRegions = _executionRegions.TryGetValue( + simple.Clause, + out var analyzedRegions) + ? analyzedRegions + : simple.ExecutionRegions; + var executionRegions = new ExecutionRegionSyntax[sourceRegions.Count]; for (var index = 0; index < executionRegions.Length; index++) { - var region = simple.ExecutionRegions[index]; + var region = sourceRegions[index]; executionRegions[index] = region with { Body = RewriteBlock(region.Body, facts), diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index 3480239..52175b9 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -90,7 +90,7 @@ public void Static_command_spellings_share_the_canonical_receiver(string source) } [Fact] - public void Module_qualified_receiver_is_catalogued_but_not_admitted_before_body_emission() + public void Module_qualified_receiver_is_admitted_once_body_emission_is_guaranteed() { var resolved = PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( "Microsoft.PowerShell.Core\\ForEach-Object", @@ -100,9 +100,13 @@ public void Module_qualified_receiver_is_catalogued_but_not_admitted_before_body Assert.True(resolved); Assert.Equal("ForEach-Object", canonical); - Assert.True(parsed.IsUnparseable); - Assert.Empty(parsed.Commands); - Assert.Empty(parsed.Clauses); + Assert.False(parsed.IsUnparseable); + Assert.Equal(2, parsed.Commands.Count); + 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)); } [Theory] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 5f2b512..c3bbf73 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -10,6 +10,126 @@ namespace ShellSyntaxTree.Tests.Parsing; public class PwshExecutionRegionStructuralTests { + [Theory] + [InlineData( + "Measure-Command { Get-Item child.txt }", + "Measure-Command")] + [InlineData( + "Trace-Command -Name ParameterBinding -Expression { Get-Item child.txt } -PSHost", + "Trace-Command")] + public void Current_scope_once_receivers_publish_typed_regions( + string source, + string expectedHost) + { + 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( + new[] { expectedHost, "Get-Item" }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + Assert.All(result.Commands, command => Assert.True(command.IsComplete)); + } + + [Fact] + public void Current_scope_once_receiver_propagates_binding_state() + { + var result = ParseIsolated( + "Measure-Command { foreach ($x in 'inner') { } }; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.True(continuation.IsComplete); + Assert.Equal( + new[] { "inner" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + } + + [Fact] + public void Current_scope_receiver_joins_inner_outcomes_before_host_continuation() + { + var result = ParseIsolated( + "Measure-Command { Set-Location /tmp } && 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); + } + + [Fact] + public void Receiver_identity_is_bound_before_its_own_common_parameter_mutation() + { + var result = ParseIsolated( + "Measure-Command { Get-Date } -OutVariable measurement"); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Main, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Once, region.Cardinality); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + } + + [Fact] + public void Conflicting_receiver_identity_across_loop_visits_stays_unknown() + { + var result = ParseIsolated( + "foreach ($x in @('first','second')) { " + + "Measure-Command { Set-Alias Measure-Command Write-Output } }"); + + var loop = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var host = Assert.IsType(Assert.Single(loop.Body.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 Observed_command_mutation_downgrades_a_later_receiver() + { + var result = ParseIsolated( + "Set-Alias Measure-Command Write-Output; " + + "Measure-Command { Remove-Item victim.txt }; Get-Item later.txt"); + + var host = result.Syntax.Statements + .OfType() + .SelectMany(list => list.Items) + .Select(item => item.Command) + .OfType() + .Single(command => command.Clause.Verb.Tokens[0] == "Measure-Command"); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.False(result.Commands.Last().IsComplete); + } + + [Fact] + public void Module_qualified_receiver_remains_proved_after_alias_mutation() + { + var result = ParseIsolated( + "Set-Alias Measure-Command Write-Output; " + + "Microsoft.PowerShell.Utility\\Measure-Command { Get-Date }"); + + Assert.False(result.IsUnparseable); + var host = result.Syntax.Statements + .OfType() + .SelectMany(list => list.Items) + .Select(item => item.Command) + .OfType() + .Last(); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Main, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Once, region.Cardinality); + Assert.True(result.Commands[1].IsComplete); + Assert.False(result.Commands[2].IsComplete); + } + [Fact] public void Command_script_block_is_exposed_as_an_unknown_incomplete_region() { @@ -178,6 +298,19 @@ public void Unknown_region_in_current_scope_wrapper_poisons_inner_and_outer_cont }); } + [Fact] + public void Unknown_region_in_direct_child_can_escape_command_resolution_state() + { + var result = Parse( + "& { Invoke-Custom { Set-Alias erase Get-Date -Scope Global } }; " + + "erase target.txt"); + + var continuation = result.Commands.Last(); + Assert.Equal("erase", continuation.Clause.Verb.Tokens[0]); + Assert.False(continuation.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, continuation.WorkingDirectory.Kind); + } + [Fact] public void Unknown_region_in_child_wrapper_does_not_poison_outer_continuation() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 6fe01de..d6cf2c3 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -981,6 +981,45 @@ public void PowerShell_variable_writer_parameters_overwrite_existing_bindings() Lines(output)); } + [Fact] + public void PowerShell_current_scope_receivers_and_data_blocks_have_distinct_state() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$x='outer'; Measure-Command { $x='measure' } | Out-Null; " + + "\"measure=<$x>\"; " + + "$x='outer'; Trace-Command -Name ParameterBinding " + + "-Expression { $x='trace' } -PSHost *> $null; \"trace=<$x>\"; " + + "$x='outer'; Write-Output { $x='data' } | Out-Null; \"data=<$x>\"; " + + "$x='outer'; Set-Alias Measure-Command Write-Output; " + + "Measure-Command { $x='shadowed' } | Out-Null; \"shadowed=<$x>\"; " + + "Microsoft.PowerShell.Utility\\Measure-Command { $x='qualified' } | " + + "Out-Null; \"qualified=<$x>\"; " + + "$ErrorActionPreference='SilentlyContinue'; Set-Location /; " + + "Measure-Command { Set-Location /definitely-missing-sst } | Out-Null; " + + "\"innerFailureHostStatus=<$?>\""); + + Assert.Equal( + new[] + { + "measure=", + "trace=", + "data=", + "shadowed=", + "qualified=", + "innerFailureHostStatus=", + }, + Lines(output)); + } + private static bool IsAvailable(string executable) { try