From 4295420844e892f9f7dc01a26392fc8e5526b610 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 23:15:47 +0000 Subject: [PATCH] Add direct PowerShell execution regions --- IMPLEMENTATION_PLAN.md | 11 +- .../Pwsh/Parsing/PwshCommandParser.cs | 18 +- .../Pwsh/Parsing/PwshForEachValueAnalysis.cs | 280 ++++++++++++++++++ .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 51 ++++ .../Corpus/CorpusRunnerTests.cs | 106 +++++++ .../162_dynamic_call_scriptblock.json | 106 ++++++- .../DesignCorpus/v0.3/powershell.json | 2 + .../Parsing/PwshDirectExecutionRegionTests.cs | 274 +++++++++++++++++ .../Parsing/PwshStructuralProjectionTests.cs | 1 - tools/PwshCorpusTool/CorpusJson.cs | 35 +++ tools/PwshCorpusTool/CorpusManifest.cs | 6 +- 11 files changed, 876 insertions(+), 14 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/PwshDirectExecutionRegionTests.cs diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 82dae76..1a44efd 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -380,8 +380,15 @@ priorities. 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. Continue in small slices with direct `&` / `.` and synchronous - current-runspace callbacks; child process/runspace jobs and parallel + expose every body command. 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 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 d5ff2e8..d8c51f0 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -206,8 +206,14 @@ private static bool TryDetectUnsupportedInvocationShape( index + 1 < tokens.Count && tokens[index + 1].Kind == PwshTokenKind.ScriptBlock) { - reason = "call-operator script blocks are not supported in v0.3"; - return true; + if (!verbSlot) + { + reason = "a call-operator script block is only supported at command position"; + return true; + } + + verbSlot = false; + continue; } verbSlot = token.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&"; @@ -218,8 +224,12 @@ private static bool TryDetectUnsupportedInvocationShape( { if (token.Value == ".") { - reason = "the dot-source invocation operator is not supported in v0.2"; - return true; + if (index + 1 >= tokens.Count || + tokens[index + 1].Kind != PwshTokenKind.ScriptBlock) + { + reason = "the dot-source invocation operator is not supported in v0.2"; + return true; + } } if (IsUnsupportedModuleQualifiedCmdlet(token.Value)) diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index a834e52..9a440ab 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -400,6 +400,187 @@ internal static bool TryGetEffect( return true; } + internal static bool MayEscapeChildScope( + Clause clause, + IReadOnlyList effectiveArguments) + { + var verb = clause.Verb.CanonicalVerb ?? + (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); + if (verb is null) + { + return true; + } + + var hasLocalProviderTarget = false; + for (var elementIndex = 0; elementIndex < clause.Elements.Count; elementIndex++) + { + var element = clause.Elements[elementIndex]; + if (element.Role != ClauseElementRole.Argument) + { + continue; + } + + if (IsOpaqueSplat(element)) + { + return true; + } + + if (element.Kind is ArgKind.EnvVar or ArgKind.DynamicSkip && + DynamicArgumentMayEscapeChildScope(elementIndex, effectiveArguments)) + { + return true; + } + + if (TryGetParameterName(element, out var parameter)) + { + if (parameter.Equals("Global", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (IsAcceptedPrefix(parameter, "Scope", minimumLength: 1) && + ScopeMayEscapeChild(clause.Elements, elementIndex)) + { + return true; + } + } + + var value = element.Value; + if (value.IndexOf("global:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("script:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Function:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Environment:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Env:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("$env:", StringComparison.OrdinalIgnoreCase) >= 0) + { + return true; + } + + var isAliasTarget = + value.StartsWith("Alias:", StringComparison.OrdinalIgnoreCase); + var isVariableTarget = + value.StartsWith("Variable:", StringComparison.OrdinalIgnoreCase); + if ((isAliasTarget || isVariableTarget) && + !IsProvedChildLocalProviderMutation( + verb, + isAliasTarget, + isVariableTarget)) + { + return true; + } + + hasLocalProviderTarget |= isAliasTarget || isVariableTarget; + } + + if (HasVariableWritingArgument(verb, clause) || + verb.Equals("Set-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Remove-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Clear-Variable", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Alias", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Import-Alias", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return !hasLocalProviderTarget; + } + + private static bool IsProvedChildLocalProviderMutation( + string verb, + bool isAliasTarget, + bool isVariableTarget) + { + if (verb.Equals("Set-Item", StringComparison.OrdinalIgnoreCase) || + verb.Equals("New-Item", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return isVariableTarget && !isAliasTarget && + (verb.Equals("Clear-Item", StringComparison.OrdinalIgnoreCase) || + verb.Equals("Set-Content", StringComparison.OrdinalIgnoreCase)); + } + + private static bool DynamicArgumentMayEscapeChildScope( + int elementIndex, + IReadOnlyList effectiveArguments) + { + foreach (var effective in effectiveArguments) + { + if (effective.ClauseElementIndex != elementIndex) + { + continue; + } + + if (effective.Value.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet) || + effective.Value.Values.Count == 0) + { + return true; + } + + foreach (var value in effective.Value.Values) + { + if (ValueMayEscapeChildScope(value)) + { + return true; + } + } + + return false; + } + + return true; + } + + private static bool ValueMayEscapeChildScope(string value) => + value.IndexOf("global:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("script:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Function:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Environment:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("Env:", StringComparison.OrdinalIgnoreCase) >= 0 || + value.IndexOf("$env:", StringComparison.OrdinalIgnoreCase) >= 0; + + private static bool ScopeMayEscapeChild( + IReadOnlyList elements, + int parameterIndex) + { + var parameter = elements[parameterIndex].Value; + var separator = parameter.IndexOf(':', 1); + if (separator < 0) + { + separator = parameter.IndexOf('=', 1); + } + + if (separator >= 0) + { + return !IsChildLocalScope(parameter.Substring(separator + 1)); + } + + for (var index = parameterIndex + 1; index < elements.Count; index++) + { + var value = elements[index]; + if (value.Role != ClauseElementRole.Argument) + { + continue; + } + + return value.IsFlag || !IsChildLocalScope(value.Value); + } + + return true; + } + + private static bool IsChildLocalScope(string value) + { + value = TrimMatchingQuotes(value); + return value.Equals("Local", StringComparison.OrdinalIgnoreCase) || + value.Equals("Private", StringComparison.OrdinalIgnoreCase) || + value.Equals("0", StringComparison.Ordinal); + } + private static bool HasVariableWritingArgument(string verb, Clause clause) { foreach (var element in clause.Elements) @@ -801,6 +982,8 @@ internal sealed class PwshForEachValueAnalyzer private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; private long _executionRegionEffectCount; private long _nonRegionStateMutationCount; + private long _locationStateMutationCount; + private long _childScopeEscapeRiskCount; private PwshForEachValueAnalyzer( PwshParserOptions options, @@ -863,6 +1046,7 @@ private PwshFlowResult AnalyzeNode(ShellSyntaxNode node, AnalysisContext input) GroupSyntax group => AnalyzeGroup(group, input), ForEachSyntax forEach => AnalyzeForEach(forEach, input), CommandSubstitutionSyntax substitution => AnalyzeSubstitution(substitution, input), + ExecutionRegionSyntax region => AnalyzeExecutionRegion(region, input), _ => AnalyzeUnsupportedNode(input), }; @@ -926,11 +1110,19 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext if (location is not null) { _nonRegionStateMutationCount++; + _locationStateMutationCount++; if (PwshPersistentStateMutation.TryGetEffect( simple.Clause, effective, out var locationEffectUnknownCwd)) { + if (PwshPersistentStateMutation.MayEscapeChildScope( + simple.Clause, + effective)) + { + _childScopeEscapeRiskCount++; + } + var flow = location.Value; return ApplyExecutionRegionEffect(simple, new PwshFlowResult( flow.OnSuccess is AnalysisContext success @@ -947,6 +1139,7 @@ flow.OnFailure is AnalysisContext failure if (simple.Clause.Verb.IsDynamic) { _nonRegionStateMutationCount++; + _childScopeEscapeRiskCount++; return ApplyExecutionRegionEffect( simple, PwshFlowResult.Both(current.Invalidate(unknownCwd: true))); @@ -958,6 +1151,13 @@ flow.OnFailure is AnalysisContext failure out var unknownCwd)) { _nonRegionStateMutationCount++; + if (PwshPersistentStateMutation.MayEscapeChildScope( + simple.Clause, + effective)) + { + _childScopeEscapeRiskCount++; + } + return ApplyExecutionRegionEffect( simple, PwshFlowResult.Both(current.Invalidate(unknownCwd))); @@ -1118,6 +1318,8 @@ private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) var executionRegionEffectCount = _executionRegionEffectCount; var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var locationStateMutationCount = _locationStateMutationCount; + var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; AnalyzeBlock( group.Body, input.WithoutBindings().Invalidate( @@ -1125,6 +1327,8 @@ private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) invalidateCommandResolution: false)); _executionRegionEffectCount = executionRegionEffectCount; _nonRegionStateMutationCount = nonRegionStateMutationCount; + _locationStateMutationCount = locationStateMutationCount; + _childScopeEscapeRiskCount = childScopeEscapeRiskCount; return PwshFlowResult.Both(input); } @@ -1135,6 +1339,78 @@ private PwshFlowResult AnalyzeSubstitution( return AnalyzeBlock(substitution.Body, input); } + private PwshFlowResult AnalyzeExecutionRegion( + ExecutionRegionSyntax region, + AnalysisContext input) + { + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var locationStateMutationCount = _locationStateMutationCount; + var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; + var body = AnalyzeBlock(region.Body, input); + var locationMutated = _locationStateMutationCount > locationStateMutationCount; + var childScopeMayEscape = + _childScopeEscapeRiskCount > childScopeEscapeRiskCount; + _executionRegionEffectCount = executionRegionEffectCount + 1; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + _childScopeEscapeRiskCount = childScopeEscapeRiskCount + + (childScopeMayEscape ? 1 : 0); + + if (region.Origin == ExecutionRegionOrigin.DotSource) + { + return body; + } + + if (region.Origin != ExecutionRegionOrigin.DirectCall) + { + return PwshFlowResult.Both(input.Invalidate(unknownCwd: true)); + } + + return new PwshFlowResult( + RestoreDirectCallExit( + body.OnSuccess, + input, + locationMutated, + childScopeMayEscape), + RestoreDirectCallExit( + body.OnFailure, + input, + locationMutated, + childScopeMayEscape)); + } + + private static AnalysisContext? RestoreDirectCallExit( + AnalysisContext? bodyExit, + AnalysisContext input, + bool locationMutated, + bool childScopeMayEscape) + { + if (bodyExit is null) + { + return null; + } + + var restored = childScopeMayEscape + ? input.Invalidate(unknownCwd: false) + : input; + if (!locationMutated && string.Equals( + bodyExit.Value.WorkingDirectory, + input.WorkingDirectory, + StringComparison.Ordinal)) + { + return restored; + } + + if (bodyExit.Value.WorkingDirectory is string workingDirectory) + { + return restored.WithCwd(workingDirectory); + } + + return locationMutated + ? restored.Invalidate(unknownCwd: true) + : restored.WithCwd(workingDirectory: null); + } + private PwshFlowResult AnalyzeForEach(ForEachSyntax forEach, AnalysisContext input) { _nonRegionStateMutationCount++; @@ -1483,6 +1759,10 @@ private ShellSyntaxNode RewriteNode( { Body = RewriteBlock(substitution.Body, facts), }, + ExecutionRegionSyntax region => region with + { + Body = RewriteBlock(region.Body, facts), + }, _ => node, }; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index bbc039f..b7bd2ef 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -346,6 +346,11 @@ private bool TryParseCommand( return false; } + if (TryParseDirectExecutionRegion(out command, out error)) + { + return error is null; + } + if (IsOperator("(")) { return TryParseGroup(compatibilityOperator, out command, out error); @@ -573,6 +578,52 @@ private bool TryParseCommand( return true; } + private bool TryParseDirectExecutionRegion( + out ShellSyntaxNode? command, + out string? error) + { + command = null; + error = null; + var origin = IsOperator("&") + ? ExecutionRegionOrigin.DirectCall + : _tokens[_position].Kind == PwshTokenKind.Word && + _tokens[_position].Value == "." + ? ExecutionRegionOrigin.DotSource + : ExecutionRegionOrigin.Unknown; + if (origin == ExecutionRegionOrigin.Unknown || + _position + 1 >= _tokens.Count || + _tokens[_position + 1].Kind != PwshTokenKind.ScriptBlock) + { + return false; + } + + var token = _tokens[_position + 1]; + var next = _position + 2; + if (next < _tokens.Count && !IsStructuralBoundary(_tokens[next])) + { + error = "direct PowerShell script-block arguments are not supported"; + return true; + } + + if (!TryParseScriptBlockBody(token, out var body, out error)) + { + return true; + } + + _position = next; + command = new ExecutionRegionSyntax + { + Origin = origin, + Phase = ExecutionRegionPhase.Main, + Timing = ExecutionRegionTiming.Synchronous, + Cardinality = ExecutionRegionCardinality.Once, + Body = body, + SourceStart = token.SourceStart, + SourceLength = token.SourceLength, + }; + return true; + } + private bool TryParseCommandExecutionRegions( Clause clause, out IReadOnlyList executionRegions, diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index 19b8202..aab8b92 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -300,6 +300,7 @@ private static void AssertAuthoredTokenCoverage( var pwshTokens = PwshLexer.Tokenize(parsed.Source); var pwshDirectSegments = DirectPwshSegments(parsed, pwshTokens); var standaloneSubstitutions = StandaloneSubstitutionRegions(parsed.Syntax); + var directExecutionRegions = DirectExecutionRegions(parsed.Syntax); var pwshSegment = 0; var pwshRedirectTargetPending = false; foreach (var token in pwshTokens) @@ -319,6 +320,10 @@ private static void AssertAuthoredTokenCoverage( || isRedirectOperator || isRedirectTarget) && !IsPwshForEachStructuralToken(parsed.Syntax, token) && + !IsDirectExecutionRegionToken( + parsed.Source, + token, + directExecutionRegions) && !standaloneSubstitutions.Any(region => region.Start <= token.SourceStart && region.Start + region.Length >= @@ -575,6 +580,107 @@ private static void CollectStandaloneSubstitutionRegions( private readonly record struct SourceRegion(int Start, int Length); + private static IReadOnlyList DirectExecutionRegions( + ShellSyntaxNode syntax) + { + var regions = new List(); + CollectDirectExecutionRegions(syntax, regions); + return regions; + } + + private static void CollectDirectExecutionRegions( + ShellSyntaxNode node, + ICollection regions) + { + switch (node) + { + case ExecutionRegionSyntax region: + if ((region.Origin is ExecutionRegionOrigin.DirectCall or + ExecutionRegionOrigin.DotSource) && + region.SourceStart.HasValue && region.SourceLength.HasValue) + { + regions.Add(new DirectExecutionRegionSource( + region.SourceStart.Value, + region.SourceLength.Value, + region.Origin)); + } + + CollectDirectExecutionRegions(region.Body, regions); + break; + case SimpleCommandSyntax simple: + foreach (var substitution in simple.Substitutions) + { + CollectDirectExecutionRegions(substitution, regions); + } + + foreach (var region in simple.ExecutionRegions) + { + CollectDirectExecutionRegions(region, regions); + } + + break; + case CommandSubstitutionSyntax substitution: + CollectDirectExecutionRegions(substitution.Body, regions); + break; + case ShellBlockSyntax block: + foreach (var statement in block.Statements) + { + CollectDirectExecutionRegions(statement, regions); + } + + break; + case PipelineSyntax pipeline: + foreach (var stage in pipeline.Stages) + { + CollectDirectExecutionRegions(stage, regions); + } + + break; + case CommandListSyntax list: + foreach (var item in list.Items) + { + CollectDirectExecutionRegions(item.Command, regions); + } + + break; + case GroupSyntax group: + CollectDirectExecutionRegions(group.Body, regions); + break; + } + } + + private static bool IsDirectExecutionRegionToken( + string source, + PwshToken token, + IReadOnlyList regions) + { + var tokenEnd = token.SourceStart + token.SourceLength; + foreach (var region in regions) + { + if (token.SourceStart >= region.Start && + tokenEnd <= region.Start + region.Length) + { + return true; + } + + if (region.Origin == ExecutionRegionOrigin.DotSource && + token.Kind == PwshTokenKind.Word && token.Value == "." && + tokenEnd <= region.Start && + source.Substring(tokenEnd, region.Start - tokenEnd) + .All(char.IsWhiteSpace)) + { + return true; + } + } + + return false; + } + + private readonly record struct DirectExecutionRegionSource( + int Start, + int Length, + ExecutionRegionOrigin Origin); + private static HashSet DirectBashSegments( ParsedCommand parsed, IReadOnlyList tokens) { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json index 1fdfee2..54d5797 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json @@ -2,9 +2,107 @@ "name": "Dynamic call scriptblock", "input": "\u0026 { Get-Date }", "expected": { - "isUnparseable": true, - "unparseableReasonContains": "call-operator script blocks are not supported in v0.3" + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ExecutionRegion", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 12, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "DirectCall", + "hostClauseElementIndex": null, + "executionPhase": "Main", + "executionTiming": "Synchronous", + "executionCardinality": "Once" + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 3, + "sourceLength": 10, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 2, + "region": "Statement", + "childIndex": 0, + "sourceStart": 4, + "sourceLength": 8, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "ExecutionRegion", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 2, + "sourceLength": 12 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 3, + "sourceLength": 10 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] }, - "notes": "The call operator executes a script block. Stable v0.3 fails closed until its body and scope are modeled.", - "oracleExpectation": "OutOfScope" + "notes": "The direct call operator is a typed synchronous child-scope region and exposes its body without a synthetic host clause." } diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index 4312147..e3ae8cf 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -128,6 +128,7 @@ { "id": "pwsh-call-operator-script-block-region", "concern": "Direct call-operator script block exposes its body without a synthetic host command", + "compatibilityProjectionLanded": true, "input": "& { Remove-Item target.txt }", "current": { "isUnparseable": true }, "desired": { @@ -2169,6 +2170,7 @@ { "id": "pwsh-dot-source-script-block-region", "concern": "Direct dot-source script block exposes a current-scope body without a synthetic host command", + "compatibilityProjectionLanded": true, "input": ". { Get-Location }", "current": { "isUnparseable": true }, "desired": { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshDirectExecutionRegionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshDirectExecutionRegionTests.cs new file mode 100644 index 0000000..cfa7745 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshDirectExecutionRegionTests.cs @@ -0,0 +1,274 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Linq; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class PwshDirectExecutionRegionTests +{ + [Theory] + [InlineData("& { Remove-Item target.txt }", ExecutionRegionOrigin.DirectCall)] + [InlineData(". { Remove-Item target.txt }", ExecutionRegionOrigin.DotSource)] + public void Direct_block_is_a_typed_region_without_a_synthetic_host( + string source, + ExecutionRegionOrigin origin) + { + var result = Parse(source); + + var region = Assert.IsType( + Assert.Single(result.Syntax.Statements)); + Assert.Equal(origin, region.Origin); + Assert.Null(region.HostClauseElementIndex); + Assert.Equal(ExecutionRegionPhase.Main, region.Phase); + Assert.Equal(ExecutionRegionTiming.Synchronous, region.Timing); + Assert.Equal(ExecutionRegionCardinality.Once, region.Cardinality); + Assert.Equal(source.IndexOf('{'), region.SourceStart); + Assert.Equal("{ Remove-Item target.txt }".Length, region.SourceLength); + + var occurrence = Assert.Single(result.Commands); + Assert.Equal("Remove-Item", Assert.Single(occurrence.Clause.Verb.Tokens)); + Assert.Equal(CommandOccurrenceRole.ExecutionRegion, occurrence.ImmediateRole); + Assert.True(occurrence.IsComplete); + Assert.Single(result.Clauses); + } + + [Theory] + [InlineData("& { Write-Output $args } alpha")] + [InlineData(". { Write-Output $args } alpha")] + [InlineData("& { param($x) Write-Output $x }")] + [InlineData(". { param($x) Write-Output $x }")] + [InlineData("& { $x = 'changed' }")] + [InlineData(". { $x = 'changed'; Set-Location /tmp }")] + public void Unsupported_direct_block_binding_fails_atomically(string source) + { + var result = Parser().Parse(source); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + } + + [Fact] + public void Direct_call_isolates_binding_mutation_but_dot_source_shares_it() + { + var direct = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "& { Write-Output inner -OutVariable x }; Write-Output $x"); + var dotSource = ParseIsolated( + "foreach ($x in 'outer') { }; " + + ". { Write-Output inner -OutVariable x }; Write-Output $x"); + + var directContinuation = direct.Commands.Last(); + Assert.True(directContinuation.IsComplete); + Assert.Equal( + new[] { "outer" }, + Assert.Single(directContinuation.EffectiveArguments).Value.Values); + + var dotSourceContinuation = dotSource.Commands.Last(); + Assert.False(dotSourceContinuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(dotSourceContinuation.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("& { Set-Location /tmp }; Get-Item relative.txt")] + [InlineData(". { Set-Location /tmp }; Get-Item relative.txt")] + public void Direct_regions_propagate_location_outcomes(string source) + { + var result = ParseIsolated(source); + + var continuation = result.Commands.Last(); + Assert.True(continuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + continuation.WorkingDirectory.Kind); + Assert.Contains( + continuation.Clause.Args, + argument => argument.IsCwdAttribution && argument.Raw == ""); + } + + [Fact] + public void Dynamic_direct_call_body_invalidates_possible_escaping_state() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; & { & $command }; " + + "Write-Output $x; Get-Item relative.txt"); + + var variableContinuation = result.Commands[^2]; + Assert.False(variableContinuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(variableContinuation.EffectiveArguments).Value.Kind); + + var pathContinuation = result.Commands[^1]; + Assert.Equal( + ShellValueDomainKind.Unknown, + pathContinuation.WorkingDirectory.Kind); + Assert.Contains( + pathContinuation.Clause.Args, + argument => argument.IsCwdAttribution && argument.Raw == ""); + } + + [Fact] + public void Direct_call_isolates_alias_mutation_but_dot_source_shares_it() + { + var direct = ParseIsolated( + "& { Set-Alias zz Write-Output }; zz value"); + var dotSource = ParseIsolated( + ". { Set-Alias zz Write-Output }; zz value"); + + Assert.True(direct.Commands.Last().IsComplete); + Assert.False(dotSource.Commands.Last().IsComplete); + } + + [Theory] + [InlineData("& { Set-Alias zz Write-Output -Scope Global }; zz value")] + [InlineData("& { Set-Item Env:PATH C:/tools }; git status")] + [InlineData("& { Set-Item Function:global:zz -Value 'Write-Output marker' }; zz")] + [InlineData("& { Set-Item Function:prompt -Value 'Write-Output marker' }; prompt")] + [InlineData("& { Set-Item -Path Alias:zz,Env:SST_SCOPE_TEST -Value changed }; git status")] + [InlineData("& { Remove-Item Variable:x }; Write-Output $x")] + [InlineData("& { Rename-Item Variable:x renamed }; Write-Output $x")] + [InlineData("& { Remove-Item Alias:where }; where")] + [InlineData("& { Rename-Item Alias:where renamed }; where")] + [InlineData("& { Clear-Item Alias:where }; where")] + [InlineData("& { Remove-Alias erase }; erase target.txt")] + public void Explicit_child_scope_escape_invalidates_the_outer_continuation( + string source) + { + var result = ParseIsolated(source); + + Assert.False(result.Commands.Last().IsComplete); + } + + [Theory] + [InlineData("Set-Item Variable:x changed")] + [InlineData("New-Item Variable:x -Value changed -Force")] + [InlineData("Clear-Item Variable:x")] + [InlineData("Set-Content Variable:x changed")] + public void Proved_child_local_variable_provider_mutation_does_not_escape( + string mutation) + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + $"& {{ {mutation} }}; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.True(continuation.IsComplete); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + } + + [Theory] + [InlineData("Global")] + [InlineData("Script")] + [InlineData("1")] + public void Escaping_variable_scope_invalidates_the_outer_binding( + string scope) + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + $"& {{ Set-Variable x changed -Scope {scope} }}; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.False(continuation.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(continuation.EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("Local")] + [InlineData("Private")] + [InlineData("0")] + [InlineData(":Local")] + public void Child_local_variable_scope_does_not_escape( + string scope) + { + var separator = scope.StartsWith(':') ? string.Empty : " "; + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + $"& {{ Set-Variable x changed -Scope{separator}{scope} }}; Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.True(continuation.IsComplete); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + } + + [Fact] + public void Escaping_mutation_in_a_nested_child_host_remains_isolated() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + "& { pwsh -Command 'Set-Variable x changed -Scope Global' }; " + + "Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.True(continuation.IsComplete); + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + } + + [Theory] + [InlineData("global:x", false)] + [InlineData("script:x", false)] + [InlineData("x", true)] + public void Analyzed_dynamic_variable_name_respects_its_effective_scope( + string name, + bool expectedComplete) + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; " + + $"foreach ($name in '{name}') {{ " + + "& { Set-Variable -Name $name -Value changed } }; " + + "Write-Output $x"); + + var continuation = result.Commands.Last(); + Assert.Equal(expectedComplete, continuation.IsComplete); + if (expectedComplete) + { + Assert.Equal( + new[] { "outer" }, + Assert.Single(continuation.EffectiveArguments).Value.Values); + } + else + { + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(continuation.EffectiveArguments).Value.Kind); + } + } + + private static ParsedCommand Parse(string source) + { + var result = Parser().Parse(source); + Assert.False(result.IsUnparseable, result.UnparseableReason); + return result; + } + + private static ParsedCommand ParseIsolated(string source) + { + var result = Parser(PwshInitialStateMode.IsolatedNonInteractiveNoProfile) + .Parse(source); + Assert.False(result.IsUnparseable, result.UnparseableReason); + return result; + } + + private static PwshParser Parser( + PwshInitialStateMode initialStateMode = PwshInitialStateMode.Unknown) => + new(new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + InitialStateMode = initialStateMode, + }); +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs index b0ae9ca..e4b331c 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs @@ -258,7 +258,6 @@ public void Dynamic_provider_failure_promotes_a_quoted_comma_filename( [InlineData("Get-Date | (Get-Process)")] [InlineData("Write-Output (Get-Date)")] [InlineData("& (Get-Command Get-Date)")] - [InlineData("& { Get-Date }")] public void Unsupported_execution_bearing_expression_shapes_fail_closed(string source) { var result = Parse(source); diff --git a/tools/PwshCorpusTool/CorpusJson.cs b/tools/PwshCorpusTool/CorpusJson.cs index cde55d6..0ab2f05 100644 --- a/tools/PwshCorpusTool/CorpusJson.cs +++ b/tools/PwshCorpusTool/CorpusJson.cs @@ -125,6 +125,7 @@ private static void AppendSyntax( var currentIndex = nodes.Count; var clause = (node as SimpleCommandSyntax)?.Clause; var forEachNode = node as ForEachSyntax; + var executionRegionNode = node as ExecutionRegionSyntax; var clauseIndex = clause is null ? (int?)null : FindClauseIndex(parsed, clause); var syntax = new JsonObject { @@ -148,6 +149,16 @@ private static void AppendSyntax( syntax["iterableSourceStart"] = forEachNode.Iterable.SourceStart; syntax["iterableSourceLength"] = forEachNode.Iterable.SourceLength; } + if (executionRegionNode is not null) + { + syntax["executionOrigin"] = executionRegionNode.Origin.ToString(); + syntax["hostClauseElementIndex"] = + JsonValue.Create(executionRegionNode.HostClauseElementIndex); + syntax["executionPhase"] = executionRegionNode.Phase.ToString(); + syntax["executionTiming"] = executionRegionNode.Timing.ToString(); + syntax["executionCardinality"] = + executionRegionNode.Cardinality.ToString(); + } nodes.Add(syntax); @@ -185,6 +196,19 @@ private static void AppendSyntax( includeV03Assertions); } + for (var index = 0; index < simple.ExecutionRegions.Count; index++) + { + AppendSyntax( + simple.ExecutionRegions[index], + currentIndex, + CommandAncestryRegion.ExecutionRegion, + index, + listOperator: null, + parsed, + nodes, + includeV03Assertions); + } + break; case PipelineSyntax pipeline: for (var index = 0; index < pipeline.Stages.Count; index++) @@ -326,6 +350,17 @@ private static void AppendSyntax( nodes, includeV03Assertions); break; + case ExecutionRegionSyntax executionRegion: + AppendSyntax( + executionRegion.Body, + currentIndex, + CommandAncestryRegion.ExecutionRegion, + childIndex: 0, + listOperator: null, + parsed, + nodes, + includeV03Assertions); + break; default: throw new InvalidOperationException( $"Cannot generate corpus expectations for syntax type {node.GetType().FullName}"); diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index b211b97..2757570 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -186,7 +186,7 @@ private static string NestIex(string inner, int depth) E("pipeline_foreach_alias", "Get-ChildItem | foreach { $_ }", "foreach after a pipe is the ForEach-Object alias, not the loop keyword."), E("pipeline_percent_block", "gci | % { Remove-Item $_ }", - "% alias resolves to ForEach-Object; body is opaque."), + "% resolves to ForEach-Object; its unknown execution region keeps the body command visible."), E("pipeline_ps_sort", "ps | sort CPU", "Two aliases piped."), E("pipeline_four_stage", "gci -Recurse C:\\logs | ? Name | sort | rm", "Four-stage pipeline mixing flags, opaque, and aliases."), @@ -398,8 +398,8 @@ private static string NestIex(string inner, int depth) "A bare dynamic call with no args."), E("dynamic_variable_command", "$cmd", "A bare variable at statement position is a dynamic command name."), - Oos("dynamic_call_scriptblock", "& { Get-Date }", - "The call operator executes a script block. Stable v0.3 fails closed until its body and scope are modeled."), + V("dynamic_call_scriptblock", "& { Get-Date }", + "The direct call operator is a typed synchronous child-scope region and exposes its body without a synthetic host clause."), E("dynamic_env_path_arg", "Get-Content $env:TEMP\\session.log", "An $env: reference in a path slot resolves to DynamicSkip."), E("dynamic_variable_path_arg", "Remove-Item $targetPath",