diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 02243be..82dae76 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -359,8 +359,16 @@ priorities. design-corpus categories are delivered. The shared projector, compatibility flattener, depth guard, decoded-wrapper cloning, and executable-corpus DTOs now preserve direct and command-owned regions in - the locked substitution-host-region order. No parser emits a region yet, - and automated execution-region oracle coverage remains in task 7.7. + the locked substitution-host-region order. The PowerShell structural + parser now emits command-argument script blocks as conservative unknown, + incomplete regions, recursively exposes supported body commands, retains + pure output expressions without inventing command occurrences, and fails + atomically on unsupported execution-bearing expressions. Until a receiver + contract proves scope and timing, an unknown region also poisons + subsequent observing cwd, variable, and command-resolution facts so a + continuation cannot reuse stale authorization evidence. Receiver-aware + typed facts and shell-specific state flow remain in tasks 7.5b-7.5e, and + automated execution-region oracle coverage remains in task 7.7. The PowerShell 7.6.4 receiver and parameter-binding catalog is now implemented with command-resolution proof as an explicit input. It pins aliases, supported module qualification, exact and abbreviated/inline diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index ad36c11..d5ff2e8 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -413,7 +413,7 @@ private static bool NextIsAssignmentOperator(IReadOnlyList tokens, in } var v = tokens[j].Value; - return v is "=" or "+=" or "-=" or "*=" or "/=" or "%="; + return v is "=" or "+=" or "-=" or "*=" or "/=" or "%=" or "??="; } return false; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index d7a991f..a834e52 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -799,6 +799,8 @@ internal sealed class PwshForEachValueAnalyzer new(ClauseReferenceComparer.Instance); private bool _isComplete = true; private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; + private long _executionRegionEffectCount; + private long _nonRegionStateMutationCount; private PwshForEachValueAnalyzer( PwshParserOptions options, @@ -861,9 +863,15 @@ private PwshFlowResult AnalyzeNode(ShellSyntaxNode node, AnalysisContext input) GroupSyntax group => AnalyzeGroup(group, input), ForEachSyntax forEach => AnalyzeForEach(forEach, input), CommandSubstitutionSyntax substitution => AnalyzeSubstitution(substitution, input), - _ => PwshFlowResult.Both(input.Invalidate(unknownCwd: true)), + _ => AnalyzeUnsupportedNode(input), }; + private PwshFlowResult AnalyzeUnsupportedNode(AnalysisContext input) + { + _nonRegionStateMutationCount++; + return PwshFlowResult.Both(input.Invalidate(unknownCwd: true)); + } + private PwshFlowResult AnalyzeBlock(ShellBlockSyntax block, AnalysisContext input) { var flow = PwshFlowResult.Both(input); @@ -899,7 +907,8 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext var effective = CreateEffectiveArguments( source.ValueProvenance, current, - includeUnresolved: isForEachIncomplete); + includeUnresolved: isForEachIncomplete || + current.CommandResolutionInvalidated); var mayPromote = current.CanPromote && source.HasCompleteValueProvenance && simple.Substitutions.Count == 0 && @@ -916,27 +925,31 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext var location = AnalyzeSetLocation(simple, current); if (location is not null) { + _nonRegionStateMutationCount++; if (PwshPersistentStateMutation.TryGetEffect( simple.Clause, effective, out var locationEffectUnknownCwd)) { var flow = location.Value; - return new PwshFlowResult( + return ApplyExecutionRegionEffect(simple, new PwshFlowResult( flow.OnSuccess is AnalysisContext success ? success.Invalidate(locationEffectUnknownCwd) : null, flow.OnFailure is AnalysisContext failure ? failure.Invalidate(locationEffectUnknownCwd) - : null); + : null)); } - return location.Value; + return ApplyExecutionRegionEffect(simple, location.Value); } if (simple.Clause.Verb.IsDynamic) { - return PwshFlowResult.Both(current.Invalidate(unknownCwd: true)); + _nonRegionStateMutationCount++; + return ApplyExecutionRegionEffect( + simple, + PwshFlowResult.Both(current.Invalidate(unknownCwd: true))); } if (PwshPersistentStateMutation.TryGetEffect( @@ -944,10 +957,32 @@ flow.OnFailure is AnalysisContext failure effective, out var unknownCwd)) { - return PwshFlowResult.Both(current.Invalidate(unknownCwd)); + _nonRegionStateMutationCount++; + return ApplyExecutionRegionEffect( + simple, + PwshFlowResult.Both(current.Invalidate(unknownCwd))); + } + + return ApplyExecutionRegionEffect(simple, PwshFlowResult.Both(current)); + } + + private PwshFlowResult ApplyExecutionRegionEffect( + SimpleCommandSyntax simple, + PwshFlowResult flow) + { + if (simple.ExecutionRegions.Count == 0) + { + return flow; } - return PwshFlowResult.Both(current); + _executionRegionEffectCount++; + return new PwshFlowResult( + flow.OnSuccess is AnalysisContext success + ? success.Invalidate(unknownCwd: true) + : null, + flow.OnFailure is AnalysisContext failure + ? failure.Invalidate(unknownCwd: true) + : null); } private void RecordFacts( @@ -1044,22 +1079,34 @@ private PwshFlowResult AnalyzeList(CommandListSyntax list, AnalysisContext input private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input) { + var stageInput = input; foreach (var stage in pipeline.Stages) { - var stageFlow = AnalyzeNode(stage, input); + var executionRegionEffectsBefore = _executionRegionEffectCount; + var nonRegionMutationsBefore = _nonRegionStateMutationCount; + var stageFlow = AnalyzeNode(stage, stageInput); if (stageFlow.JoinedState is not AnalysisContext stageExit) { return new PwshFlowResult(null, null); } - if (!input.StateEquals(stageExit)) + if (!stageInput.StateEquals(stageExit)) { - _isComplete = false; - return new PwshFlowResult(null, null); + 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(input); + return PwshFlowResult.Both(stageInput); } private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) @@ -1069,11 +1116,15 @@ private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) return AnalyzeBlock(group.Body, input); } + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; AnalyzeBlock( group.Body, input.WithoutBindings().Invalidate( unknownCwd: false, invalidateCommandResolution: false)); + _executionRegionEffectCount = executionRegionEffectCount; + _nonRegionStateMutationCount = nonRegionStateMutationCount; return PwshFlowResult.Both(input); } @@ -1086,6 +1137,7 @@ private PwshFlowResult AnalyzeSubstitution( private PwshFlowResult AnalyzeForEach(ForEachSyntax forEach, AnalysisContext input) { + _nonRegionStateMutationCount++; var plan = _planFactory(forEach); if (plan is null) { @@ -1446,6 +1498,16 @@ private SimpleCommandSyntax RewriteSimple( facts); } + var executionRegions = new ExecutionRegionSyntax[simple.ExecutionRegions.Count]; + for (var index = 0; index < executionRegions.Length; index++) + { + var region = simple.ExecutionRegions[index]; + executionRegions[index] = region with + { + Body = RewriteBlock(region.Body, facts), + }; + } + var source = GetFacts(simple); var clause = RewriteCwdCompatibility( simple.Clause, @@ -1465,6 +1527,7 @@ private SimpleCommandSyntax RewriteSimple( { Clause = clause, Substitutions = substitutions, + ExecutionRegions = executionRegions, }; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index d6df419..bbc039f 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -9,6 +9,7 @@ using ShellSyntaxTree.Internal.Parsing; using ShellSyntaxTree.Internal.Pwsh.Lexing; using ShellSyntaxTree.Internal.Resolving; +using ShellSyntaxTree.Internal.Pwsh.Verbs; namespace ShellSyntaxTree.Internal.Pwsh.Parsing; @@ -549,12 +550,21 @@ private bool TryParseCommand( UpdateAttribution(built.Clauses[0], _options, _attribution); } + if (!TryParseCommandExecutionRegions( + clause, + out var executionRegions, + out error)) + { + return false; + } + var first = segmentTokens[0]; var last = segmentTokens[segmentTokens.Count - 1]; var simple = new SimpleCommandSyntax { Clause = clause, Substitutions = substitutions, + ExecutionRegions = executionRegions, SourceStart = first.SourceStart, SourceLength = last.SourceStart + last.SourceLength - first.SourceStart, }; @@ -563,6 +573,191 @@ private bool TryParseCommand( return true; } + private bool TryParseCommandExecutionRegions( + Clause clause, + out IReadOnlyList executionRegions, + out string? error) + { + var binding = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: false); + if (binding.Status == PwshExecutionRegionBindingStatus.NotApplicable) + { + executionRegions = Array.Empty(); + error = null; + return true; + } + + if (_structuralDepth + _groupDepth + 1 > + ShellAnalysisLimits.MaxStructuralNesting) + { + executionRegions = Array.Empty(); + error = "PowerShell structural nesting depth exceeded (>16)"; + return false; + } + + var parsed = new List(binding.Bindings.Count); + error = null; + for (var index = 0; index < binding.Bindings.Count; index++) + { + var blockBinding = binding.Bindings[index]; + if (blockBinding.HostClauseElementIndex < 0 + || blockBinding.HostClauseElementIndex >= clause.Elements.Count + || !TryCreateScriptBlockToken( + clause.Elements[blockBinding.HostClauseElementIndex], + out var token) + || !TryParseScriptBlockBody(token, out var body, out error)) + { + executionRegions = Array.Empty(); + error ??= "PowerShell script-block binding could not be mapped exactly"; + return false; + } + + parsed.Add(new ExecutionRegionSyntax + { + Origin = ExecutionRegionOrigin.CommandArgument, + HostClauseElementIndex = blockBinding.HostClauseElementIndex, + Phase = blockBinding.Phase, + Timing = blockBinding.Timing, + Cardinality = blockBinding.Cardinality, + Body = body, + SourceStart = token.SourceStart, + SourceLength = token.SourceLength, + }); + } + + executionRegions = parsed; + error = null; + return true; + } + + private static bool TryCreateScriptBlockToken( + ClauseElement element, + out PwshToken token) + { + token = default; + if (element.SourceStart is null || element.SourceLength is null) + { + return false; + } + + var open = element.Raw.IndexOf('{'); + var close = element.Raw.LastIndexOf('}'); + if (open < 0 || close <= open) + { + return false; + } + + var length = close - open + 1; + token = new PwshToken( + PwshTokenKind.ScriptBlock, + element.Raw.Substring(open, length), + null, + element.SourceStart.Value + open, + length, + null); + return true; + } + + private bool TryParseScriptBlockBody( + PwshToken token, + out ShellBlockSyntax body, + out string? error) + { + if (token.SourceLength < 2) + { + body = new ShellBlockSyntax(); + error = "PowerShell script-block token was not completely delimited"; + return false; + } + + var sourceStart = token.SourceStart + 1; + var sourceLength = token.SourceLength - 2; + var source = _source.Substring(sourceStart, sourceLength); + var relativeTokens = PwshLexer.Tokenize(source); + foreach (var relativeToken in relativeTokens) + { + if (relativeToken.Kind == PwshTokenKind.UnparseableSentinel) + { + body = new ShellBlockSyntax(); + error = relativeToken.UnparseableReason; + return false; + } + } + + var significant = FilterSignificant(relativeTokens); + if (TryDetectAnomaly(significant, out error)) + { + body = new ShellBlockSyntax(); + return false; + } + + if (significant.Count > 0 && IsUnsupportedSubstitutionBody(source, significant)) + { + foreach (var expressionToken in significant) + { + if (HasPowerShellSubexpression(expressionToken) + || expressionToken.Kind is PwshTokenKind.Subexpression + or PwshTokenKind.ScriptBlock + or PwshTokenKind.Splat + || expressionToken.IsStatementSeparator + || expressionToken.Kind == PwshTokenKind.Operator) + { + body = new ShellBlockSyntax(); + error = "unsupported execution-bearing PowerShell script-block expression"; + return false; + } + } + + body = new ShellBlockSyntax + { + SourceStart = sourceStart, + SourceLength = sourceLength, + }; + error = null; + return true; + } + + var coordinator = new StructuralCoordinator( + _source, + ShiftTokens(significant, sourceStart), + _options, + _recursionDepth, + _structuralDepth + _groupDepth + 1, + _markWrapped, + _attribution, + sourceStart, + sourceLength, + CompoundOperator.None, + insideCommandSubstitution: false); + if (!coordinator.TryParse(out body, out error)) + { + return false; + } + + MergeFacts(coordinator); + return true; + } + + private static bool HasPowerShellSubexpression(PwshToken token) + { + if (token.ResolverValue is null) + { + return false; + } + + foreach (var fragment in token.ResolverValue.Fragments) + { + if (fragment.Kind == ShellValueFragmentKind.Opaque + && fragment.OpaqueCause == ShellOpaqueCause.PowerShellSubexpression) + { + return true; + } + } + + return false; + } + private static bool IsForEachCommandArgument( IReadOnlyList prefix, CompoundOperator compatibilityOperator) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/053_pipeline_percent_block.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/053_pipeline_percent_block.json index 4999396..cb5fd8e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/053_pipeline_percent_block.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/053_pipeline_percent_block.json @@ -27,8 +27,22 @@ } ], "redirects": [] + }, + { + "operator": "None", + "verb": [ + "Remove-Item" + ], + "args": [ + { + "raw": "$_", + "kind": "DynamicSkip", + "isPath": false + } + ], + "redirects": [] } ] }, - "notes": "% alias resolves to ForEach-Object; body is opaque." + "notes": "% resolves to ForEach-Object; its unknown execution region keeps the body command visible." } diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index cab04b4..4312147 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -2495,7 +2495,7 @@ "concern": "A leading param declaration keeps the complete argument-completer region atomic until declaration grammar lands", "input": "Register-ArgumentCompleter -CommandName tool -ScriptBlock { param($commandName) Write-Output $commandName }", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", - "current": { "isUnparseable": false }, + "current": { "isUnparseable": true }, "desired": { "isUnparseable": true, "syntax": [ diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs new file mode 100644 index 0000000..5f2b512 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -0,0 +1,293 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Linq; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class PwshExecutionRegionStructuralTests +{ + [Fact] + public void Command_script_block_is_exposed_as_an_unknown_incomplete_region() + { + const string source = "ForEach-Object { Remove-Item victim.txt }"; + + var result = Parse(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var region = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionOrigin.CommandArgument, region.Origin); + Assert.Equal(1, region.HostClauseElementIndex); + Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.Equal(ExecutionRegionTiming.Unknown, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Unknown, region.Cardinality); + Assert.Equal(source.IndexOf('{'), region.SourceStart); + Assert.Equal("{ Remove-Item victim.txt }".Length, region.SourceLength); + + 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" }, + 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); + Assert.Contains(result.Commands[1].Ancestry, + frame => frame.Region == CommandAncestryRegion.ExecutionRegion); + } + + [Fact] + public void Multiple_script_blocks_keep_authored_regions_and_host_coordinates() + { + const string source = + "ForEach-Object -Begin { Write-Output begin } " + + "-Process { Remove-Item one }, { Remove-Item two }"; + + var result = Parse(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal(3, host.ExecutionRegions.Count); + Assert.Equal( + new int?[] { 2, 4, 5 }, + host.ExecutionRegions.Select(region => region.HostClauseElementIndex)); + Assert.True(host.ExecutionRegions + .Select(region => region.SourceStart) + .SequenceEqual(host.ExecutionRegions.Select(region => region.SourceStart) + .OrderBy(start => start))); + Assert.All(host.ExecutionRegions, region => + { + Assert.Equal(ExecutionRegionPhase.Unknown, region.Phase); + Assert.Single(region.Body.Statements); + }); + Assert.Equal(4, result.Commands.Count); + } + + [Theory] + [InlineData("Write-Output { Remove-Item victim.txt }")] + [InlineData("Invoke-Custom { Remove-Item victim.txt }")] + public void Unproved_receiver_identity_never_hides_a_script_block(string source) + { + var result = Parse(source); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Single(host.ExecutionRegions); + Assert.Equal(2, result.Commands.Count); + Assert.False(result.Commands[1].IsComplete); + } + + [Theory] + [InlineData("Where-Object { $_.Length -gt 0 }")] + [InlineData("ForEach-Object { $_ }")] + public void Pure_output_expressions_do_not_invent_command_occurrences(string source) + { + var result = Parse(source); + + 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); + } + + [Theory] + [InlineData("ForEach-Object { $_.Delete() }", "execution-bearing")] + [InlineData( + "ForEach-Object { $value = Remove-Item victim.txt }", + "assignment statement")] + [InlineData( + "ForEach-Object { $value ??= Remove-Item victim.txt }", + "assignment statement")] + [InlineData( + "ForEach-Object { $_ -eq \"$(Remove-Item victim.txt)\" }", + "execution-bearing")] + [InlineData( + "ForEach-Object { $_ -eq @\"\n$(Remove-Item victim.txt)\n\"@ }", + "execution-bearing")] + public void Unsupported_execution_bearing_expression_fails_atomically( + string source, + string expectedReason) + { + var result = new PwshParser().Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains(expectedReason, result.UnparseableReason); + } + + [Fact] + public void Unknown_region_poisons_later_cwd_and_command_resolution_facts() + { + var location = Parse( + "Invoke-Custom { Set-Location /tmp }; Remove-Item relative.txt"); + var alias = Parse( + "ForEach-Object { Set-Alias ri Write-Output }; ri victim.txt"); + + Assert.Equal( + ShellValueDomainKind.Unknown, + location.Commands.Last().WorkingDirectory.Kind); + Assert.False(location.Commands.Last().IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + alias.Commands.Last().WorkingDirectory.Kind); + Assert.False(alias.Commands.Last().IsComplete); + } + + [Fact] + public void Unknown_region_poisons_later_variable_facts() + { + var result = ParseIsolated( + "Invoke-Custom { Set-Variable x value }; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.False(continuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(continuation.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Unknown_region_in_substitution_poisons_the_containing_host() + { + var result = ParseIsolated( + "Get-Item relative.txt $(Invoke-Custom { Set-Location /tmp })"); + + var host = result.Commands.Last(); + Assert.Equal("Get-Item", Assert.Single(host.Clause.Verb.Tokens)); + Assert.False(host.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, host.WorkingDirectory.Kind); + } + + [Fact] + public void Unknown_region_in_current_scope_wrapper_poisons_inner_and_outer_continuations() + { + var result = ParseIsolated( + "Invoke-Expression \"Invoke-Custom { Set-Location /tmp }; " + + "Get-Item inner.txt\"; Get-Item outer.txt"); + + var continuations = result.Commands + .Where(command => command.Clause.Verb.Tokens.Contains("Get-Item")) + .ToArray(); + Assert.Equal(2, continuations.Length); + Assert.All(continuations, continuation => + { + Assert.False(continuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation.WorkingDirectory.Kind); + }); + } + + [Fact] + public void Unknown_region_in_child_wrapper_does_not_poison_outer_continuation() + { + var result = ParseIsolated( + "pwsh -NoProfile -Command \"Invoke-Custom { Set-Location /tmp }; " + + "Get-Item inner.txt\"; Get-Item outer.txt"); + + var continuations = result.Commands + .Where(command => command.Clause.Verb.Tokens.Contains("Get-Item")) + .ToArray(); + Assert.Equal(2, continuations.Length); + Assert.False(continuations[0].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuations[0].WorkingDirectory.Kind); + Assert.True(continuations[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Exact, + continuations[1].WorkingDirectory.Kind); + } + + [Fact] + public void Unknown_region_in_child_wrapper_pipeline_does_not_poison_outer_continuation() + { + var result = ParseIsolated( + "pwsh -NoProfile -Command \"Invoke-Custom { Set-Location /tmp }; " + + "Get-Item inner.txt\" | Write-Output passthrough; Get-Item outer.txt"); + + var continuations = result.Commands + .Where(command => command.Clause.Verb.Tokens.Contains("Get-Item")) + .ToArray(); + Assert.Equal(2, continuations.Length); + Assert.False(continuations[0].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuations[0].WorkingDirectory.Kind); + Assert.True(continuations[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Exact, + continuations[1].WorkingDirectory.Kind); + } + + [Fact] + public void Unknown_region_poisons_later_pipeline_stages() + { + var result = ParseIsolated( + "Write-Output content | Invoke-Custom { Set-Location /tmp } | " + + "Set-Content relative-probe.txt -WhatIf"); + + var continuation = result.Commands.Last(); + Assert.Equal("Set-Content", Assert.Single(continuation.Clause.Verb.Tokens)); + Assert.False(continuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation.WorkingDirectory.Kind); + Assert.Contains( + continuation.Clause.Args, + argument => argument.IsCwdAttribution && argument.Raw == ""); + } + + [Theory] + [InlineData( + "Invoke-Expression 'pwsh -Command \"Invoke-Custom { Write-Output hi }\"; " + + "Set-Location /tmp' | Write-Output later")] + [InlineData( + "Invoke-Expression 'pwsh -Command \"Invoke-Custom { Write-Output hi }\"; " + + "Set-Alias ri Write-Output' | Write-Output later")] + [InlineData( + "Invoke-Expression 'Invoke-Custom { Write-Output hi }; " + + "Set-Location /tmp' | Write-Output later")] + [InlineData( + "Invoke-Expression 'Set-Location /tmp; " + + "Invoke-Custom { Write-Output hi }' | Write-Output later")] + [InlineData( + "Set-Location C:/work; Invoke-Expression 'Set-Alias ri Write-Output; " + + "Invoke-Custom { Write-Output hi }' | Write-Output later")] + [InlineData( + "Set-Location C:/work; Invoke-Expression 'Set-Location /tmp; " + + "Invoke-Custom { Write-Output hi }' | Write-Output later")] + public void Execution_region_does_not_mask_sibling_pipeline_mutation( + string source) + { + var result = new PwshParser(new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }).Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + private static ParsedCommand Parse(string source) + { + var result = new PwshParser().Parse(source); + Assert.False(result.IsUnparseable, result.UnparseableReason); + return result; + } + + private static ParsedCommand ParseIsolated(string source) + { + var result = new PwshParser(new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }).Parse(source); + Assert.False(result.IsUnparseable, result.UnparseableReason); + return result; + } +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index 49e11d5..617a553 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -288,7 +288,7 @@ public void Decoded_child_host_pipeline_iterator_stays_visible_without_outer_pla } [Fact] - public void Foreach_object_alias_remains_an_opaque_script_block_argument() + public void Foreach_object_alias_exposes_an_unknown_script_block_region() { var result = Parse("Get-ChildItem | foreach { Write-Output $_ }"); @@ -296,7 +296,11 @@ public void Foreach_object_alias_remains_an_opaque_script_block_argument() Assert.Equal("Get-ChildItem", CommandVerb(result.Commands[0])); Assert.Equal("ForEach-Object", result.Commands[1].Clause.Verb.CanonicalVerb); Assert.IsType(Assert.Single(result.Syntax.Statements)); - Assert.Equal(2, result.Commands.Count); + Assert.Equal(3, result.Commands.Count); + var pipeline = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var host = Assert.IsType(pipeline.Stages[1]); + Assert.Single(host.ExecutionRegions); + Assert.Equal(CommandOccurrenceRole.ExecutionRegion, result.Commands[2].ImmediateRole); } [Fact]