diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 8c64181..ff18e71 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -408,6 +408,13 @@ priorities. Pipelines with any supported synchronous execution region plus another stateful stage withhold body facts that downstream initialization or per-object interleaving can invalidate. + `New-Module` initialization now runs once synchronously from caller + state in a child module scope, propagates shared location, restores + ordinary child bindings, applies common variable-writer initialization + before the body, invalidates body identity when a writer can alter + resolution preferences, and retains conservative host command-resolution + invalidation for exported functions across canonical, alias, and supported + module-qualified identities. Child process/runspace jobs and parallel blocks; deferred breakpoint/event/completion actions; then unknown receiver and nested/adversarial matrices. Preserve script blocks proved diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index cb268c4..d31bbb9 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -367,8 +367,7 @@ internal static bool TryGetEffect( out bool unknownCwd) { unknownCwd = false; - var verb = clause.Verb.CanonicalVerb ?? - (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + var verb = GetCanonicalVerb(clause); if (verb is null) { return false; @@ -405,8 +404,7 @@ internal static bool MayEscapeChildScope( Clause clause, IReadOnlyList effectiveArguments) { - var verb = clause.Verb.CanonicalVerb ?? - (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + var verb = GetCanonicalVerb(clause); if (verb is null) { return true; @@ -488,6 +486,27 @@ internal static bool MayEscapeChildScope( return !hasLocalProviderTarget; } + internal static bool HasVariableWritingArgument(Clause clause) + { + var verb = GetCanonicalVerb(clause); + return verb is null || HasVariableWritingArgument(verb, clause); + } + + private static string? GetCanonicalVerb(Clause clause) + { + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + if (verb is not null && + PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( + verb, + out var canonicalName)) + { + return canonicalName; + } + + return verb; + } + private static bool IsProvedChildLocalProviderMutation( string verb, bool isAliasTarget, @@ -1229,6 +1248,15 @@ flow.OnFailure is AnalysisContext failure flow); } + if (binding.ParameterSet == PwshExecutionRegionParameterSet.NewModuleScriptBlock) + { + var bodyInput = PwshPersistentStateMutation.HasVariableWritingArgument( + simple.Clause) + ? receiverInput.Invalidate(unknownCwd: false) + : receiverInput; + return AnalyzeNewModule(regions[0], bodyInput, flow); + } + var executionRegionEffectCount = _executionRegionEffectCount; var nonRegionStateMutationCount = _nonRegionStateMutationCount; var bodyFlow = AnalyzeExecutionRegionBody(regions[0].Body, regionInput); @@ -1239,6 +1267,44 @@ flow.OnFailure is AnalysisContext failure : flow; } + private PwshFlowResult AnalyzeNewModule( + ExecutionRegionSyntax region, + AnalysisContext input, + PwshFlowResult hostFlow) + { + 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; + _childScopeEscapeRiskCount = childScopeEscapeRiskCount + + (childScopeMayEscape ? 1 : 0); + + var restoredExit = AnalysisContext.JoinNullable( + RestoreChildScopeExit( + body.OnSuccess, + input, + locationMutated, + childScopeMayEscape), + RestoreChildScopeExit( + body.OnFailure, + input, + locationMutated, + childScopeMayEscape)); + if (restoredExit is not AnalysisContext bodyExit || + hostFlow.JoinedState is not AnalysisContext hostExit) + { + return hostFlow; + } + + return PwshFlowResult.Both(hostExit.WithCwd(bodyExit.WorkingDirectory)); + } + private PwshFlowResult AnalyzeInProcessInvokeCommand( PwshExecutionRegionBindingResult binding, ExecutionRegionSyntax region, @@ -1497,6 +1563,7 @@ private static bool IsSupportedSynchronousReceiver( (binding.ParameterSet is PwshExecutionRegionParameterSet.MeasureExpression or PwshExecutionRegionParameterSet.TraceExpression or PwshExecutionRegionParameterSet.InvokeInProcess or + PwshExecutionRegionParameterSet.NewModuleScriptBlock or PwshExecutionRegionParameterSet.ForEachScriptBlock or PwshExecutionRegionParameterSet.WhereScriptBlock) && AllBindingsAreCompleteAndSynchronous(binding.Bindings); @@ -1762,7 +1829,8 @@ binding.ParameterSet is PwshExecutionRegionParameterSet.ForEachScriptBlock or PwshExecutionRegionParameterSet.WhereScriptBlock or PwshExecutionRegionParameterSet.InvokeInProcess or PwshExecutionRegionParameterSet.MeasureExpression or - PwshExecutionRegionParameterSet.TraceExpression; + PwshExecutionRegionParameterSet.TraceExpression or + PwshExecutionRegionParameterSet.NewModuleScriptBlock; } private static bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 877389f..0ef9b24 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -179,6 +179,110 @@ public void In_process_invoke_command_joins_body_outcomes_before_host_continuati Assert.True(continuation.IsComplete); } + [Theory] + [InlineData("New-Module { Get-Item child.txt }")] + [InlineData("nmo -ScriptBlock { Get-Item child.txt }")] + [InlineData("Microsoft.PowerShell.Core\\New-Module { Get-Item child.txt }")] + public void New_module_publishes_a_synchronous_initialization_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.Initialization, 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 New_module_body_reads_caller_state_but_host_invalidates_continuation() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; New-Module { " + + "Write-Output $x; foreach ($x in 'inner') { } }; " + + "Write-Output $x"); + + var writes = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ToArray(); + Assert.Equal(2, writes.Length); + Assert.Equal( + new[] { "outer" }, + Assert.Single(writes[0].EffectiveArguments).Value.Values); + Assert.True(writes[0].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(writes[1].EffectiveArguments).Value.Kind); + Assert.False(writes[1].IsComplete); + } + + [Theory] + [InlineData("OutVariable")] + [InlineData("PipelineVariable")] + [InlineData("ErrorVariable")] + [InlineData("WarningVariable")] + [InlineData("InformationVariable")] + public void New_module_variable_writers_invalidate_body_input(string parameter) + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + $"New-Module -ReturnResult {{ Write-Output $x }} -{parameter} x"); + + Assert.False(result.IsUnparseable); + var write = Assert.Single( + result.Commands, + command => command.Clause.Verb.Tokens[0] == "Write-Output"); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(write.EffectiveArguments).Value.Kind); + Assert.False(write.IsComplete); + } + + [Fact] + public void New_module_writer_target_invalidates_body_command_resolution() + { + var result = ParseIsolated( + "New-Module -ReturnResult { Compress-Archive a b } " + + "-OutVariable PSModuleAutoLoadingPreference"); + + Assert.False(result.IsUnparseable); + var body = Assert.Single( + result.Commands, + command => command.Clause.Verb.Tokens[0] == "Compress-Archive"); + Assert.False(body.IsComplete); + } + + [Fact] + public void Module_qualified_new_module_invalidates_exported_function_continuation() + { + var result = ParseIsolated( + "Microsoft.PowerShell.Core\\New-Module { " + + "Set-Item Function:\\git -Value 'Remove-Item child.txt' }; " + + "git child.txt"); + + Assert.False(result.IsUnparseable); + var continuation = result.Commands.Last(); + Assert.Equal("git", continuation.Clause.Verb.Tokens[0]); + Assert.False(continuation.IsComplete); + } + + [Fact] + public void New_module_joins_body_outcomes_before_host_continuation() + { + var result = ParseIsolated( + "New-Module { 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.False(continuation.IsComplete); + } + [Fact] public void Current_scope_once_receiver_propagates_binding_state() { @@ -615,6 +719,21 @@ public void Pipeline_stage_effects_make_synchronous_region_state_unknown( }); } + [Fact] + public void New_module_pipeline_state_mutation_fails_atomically() + { + var result = new PwshParser(new PwshParserOptions + { + WorkingDirectory = "C:/work", + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }).Parse( + "New-Module { Write-Output value } | Write-Output -OutVariable x"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + [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 dc25ef3..3c096ba 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -1131,7 +1131,21 @@ public void PowerShell_synchronous_regions_observe_scope_and_pipeline_stage_effe "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"); + "Write-Output -OutVariable x | Out-Null; $x; " + + "Set-Location $start; $x='outer'; New-Module { " + + "Write-Host \"module-body-x=<$x>\"; $x='inner'; " + + "Set-Location $target; Set-Alias moduleAlias Get-Date } | Out-Null; " + + "\"module-after-x=<$x>\"; " + + "\"module-cwd-target=<$((Get-Location).Path -eq $target)>\"; " + + "\"module-alias=<$([bool](Get-Alias moduleAlias -ErrorAction Ignore))>\"; " + + "Set-Location $start; New-Module { Set-Location $missing " + + "-ErrorAction SilentlyContinue } | Out-Null; " + + "\"module-failure-status=<$?>\"; " + + "\"module-failure-cwd-start=<$((Get-Location).Path -eq $start)>\"; " + + "$x='outer'; $null = New-Module -ReturnResult { " + + "Write-Host \"module-outvar=<$x>\" } -OutVariable x; " + + "$x='start'; New-Module -ReturnResult { Write-Host \"module-stage=<$x>\" } | " + + "Write-Output -OutVariable x | Out-Null"); Assert.Equal( new[] @@ -1150,6 +1164,14 @@ public void PowerShell_synchronous_regions_observe_scope_and_pipeline_stage_effe "invoke-interleave=>", "measure-stage=<>", "trace-stage=<>", + "module-body-x=", + "module-after-x=", + "module-cwd-target=", + "module-alias=", + "module-failure-status=", + "module-failure-cwd-start=", + "module-outvar=<>", + "module-stage=<>", }, Lines(output)); }