diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index ff18e71..673420e 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -415,7 +415,17 @@ priorities. 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 + `Start-Job` now schedules initialization before main in an isolated child + process state, inherits or applies the invocation working directory, and + prevents child exit mutation from contaminating the host continuation. + Inline working-directory values retain exact value provenance, while an + explicit relative working directory remains unknown because PowerShell + resolves it from a platform-specific child startup location rather than + the caller location. Known but unsupported job variants retain their + proved child-process isolation while their body analysis stays fail + closed; an explicit alternate `-PSVersion` remains visible but incomplete + because it falls outside the pinned PowerShell 7 runtime model. + Child runspace jobs and parallel blocks; deferred breakpoint/event/completion actions; then unknown receiver and nested/adversarial matrices. Preserve script blocks proved to be data as opaque values, expose ambiguous bodies with incomplete diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index d31bbb9..730c487 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -1202,10 +1202,13 @@ private PwshFlowResult ApplyExecutionRegionEffect( return flow; } + var commandIdentityProven = IsCommandIdentityProven( + simple.Clause, + receiverInput); var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, - IsCommandIdentityProven(simple.Clause, receiverInput)); - if (!IsSupportedSynchronousReceiver(binding) || + commandIdentityProven); + if (!IsSupportedExecutionRegionReceiver(binding) || !TryApplyExecutionRegionBindings(simple, binding, out var regions)) { RecordExecutionRegions( @@ -1213,6 +1216,12 @@ private PwshFlowResult ApplyExecutionRegionEffect( simple.ExecutionRegions, simple.ExecutionRegions); _executionRegionEffectCount++; + if (commandIdentityProven && + binding.Receiver == PwshExecutionRegionReceiver.StartJob) + { + return flow; + } + _childScopeEscapeRiskCount++; return new PwshFlowResult( flow.OnSuccess is AnalysisContext success @@ -1257,6 +1266,11 @@ flow.OnFailure is AnalysisContext failure return AnalyzeNewModule(regions[0], bodyInput, flow); } + if (binding.ParameterSet == PwshExecutionRegionParameterSet.StartJobScriptBlock) + { + return AnalyzeStartJob(simple, binding, regions, receiverInput, flow); + } + var executionRegionEffectCount = _executionRegionEffectCount; var nonRegionStateMutationCount = _nonRegionStateMutationCount; var bodyFlow = AnalyzeExecutionRegionBody(regions[0].Body, regionInput); @@ -1305,6 +1319,92 @@ private PwshFlowResult AnalyzeNewModule( return PwshFlowResult.Both(hostExit.WithCwd(bodyExit.WorkingDirectory)); } + private PwshFlowResult AnalyzeStartJob( + SimpleCommandSyntax simple, + PwshExecutionRegionBindingResult binding, + IReadOnlyList regions, + AnalysisContext receiverInput, + PwshFlowResult hostFlow) + { + var executionRegionEffectCount = _executionRegionEffectCount; + var nonRegionStateMutationCount = _nonRegionStateMutationCount; + var locationStateMutationCount = _locationStateMutationCount; + var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; + try + { + var child = CreateStartJobInput(simple, binding, receiverInput); + if (TryAnalyzeRegionPhase( + regions, + ExecutionRegionPhase.Initialization, + child, + out var initialized)) + { + TryAnalyzeRegionPhase( + regions, + ExecutionRegionPhase.Main, + initialized, + out _); + } + } + finally + { + _executionRegionEffectCount = executionRegionEffectCount + regions.Count; + _nonRegionStateMutationCount = nonRegionStateMutationCount; + _locationStateMutationCount = locationStateMutationCount; + _childScopeEscapeRiskCount = childScopeEscapeRiskCount; + } + + return hostFlow; + } + + private AnalysisContext CreateStartJobInput( + SimpleCommandSyntax simple, + PwshExecutionRegionBindingResult binding, + AnalysisContext receiverInput) + { + var child = receiverInput + .Invalidate(unknownCwd: false, invalidateCommandResolution: false) + .WithoutBindings(); + if (binding.WorkingDirectoryElementIndex is not int elementIndex) + { + return child; + } + + if (!TryGetElementValue( + simple, + elementIndex, + binding.WorkingDirectoryValueOffset, + out var targetValue)) + { + return child.WithCwd(workingDirectory: null); + } + + if (receiverInput.TryEvaluateValue(targetValue, out var domain) && + domain.Kind == ShellValueDomainKind.Exact && + domain.Values.Count == 1) + { + targetValue = ShellValue.Literal(domain.Values[0]); + } + + var options = new PwshParserOptions + { + HomeDirectory = _options.HomeDirectory, + WorkingDirectory = receiverInput.WorkingDirectory, + InitialStateMode = _options.InitialStateMode, + }; + var resolved = PwshResolver.Resolve( + targetValue, + treatAsPath: true, + options, + workingDirectoryUnknown: true, + ShellResolutionConsumer.PowerShellCmdletPath); + return child.WithCwd( + resolved.IsPath && + resolved.Resolved is not null + ? resolved.Resolved + : null); + } + private PwshFlowResult AnalyzeInProcessInvokeCommand( PwshExecutionRegionBindingResult binding, ExecutionRegionSyntax region, @@ -1557,19 +1657,37 @@ private static bool HaveSameCompleteRegionFacts( return true; } - private static bool IsSupportedSynchronousReceiver( - PwshExecutionRegionBindingResult binding) => - binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && - (binding.ParameterSet is PwshExecutionRegionParameterSet.MeasureExpression or - PwshExecutionRegionParameterSet.TraceExpression or - PwshExecutionRegionParameterSet.InvokeInProcess or - PwshExecutionRegionParameterSet.NewModuleScriptBlock or - PwshExecutionRegionParameterSet.ForEachScriptBlock or - PwshExecutionRegionParameterSet.WhereScriptBlock) && - AllBindingsAreCompleteAndSynchronous(binding.Bindings); + private static bool IsSupportedExecutionRegionReceiver( + PwshExecutionRegionBindingResult binding) + { + if (binding.Status != PwshExecutionRegionBindingStatus.ProvedExecution) + { + return false; + } - private static bool AllBindingsAreCompleteAndSynchronous( - IReadOnlyList bindings) + return binding.ParameterSet switch + { + PwshExecutionRegionParameterSet.StartJobScriptBlock => + !binding.HasExplicitPSVersion && + AllBindingsAreCompleteWithTiming( + binding.Bindings, + ExecutionRegionTiming.Concurrent), + PwshExecutionRegionParameterSet.MeasureExpression or + PwshExecutionRegionParameterSet.TraceExpression or + PwshExecutionRegionParameterSet.InvokeInProcess or + PwshExecutionRegionParameterSet.NewModuleScriptBlock or + PwshExecutionRegionParameterSet.ForEachScriptBlock or + PwshExecutionRegionParameterSet.WhereScriptBlock => + AllBindingsAreCompleteWithTiming( + binding.Bindings, + ExecutionRegionTiming.Synchronous), + _ => false, + }; + } + + private static bool AllBindingsAreCompleteWithTiming( + IReadOnlyList bindings, + ExecutionRegionTiming timing) { if (bindings.Count == 0) { @@ -1579,7 +1697,7 @@ private static bool AllBindingsAreCompleteAndSynchronous( for (var index = 0; index < bindings.Count; index++) { if (!bindings[index].IsComplete || - bindings[index].Timing != ExecutionRegionTiming.Synchronous) + bindings[index].Timing != timing) { return false; } @@ -2252,17 +2370,41 @@ private bool TryGetElementDomain( int elementIndex, AnalysisContext input, out ShellValueDomain domain) + { + if (TryGetElementValue(simple, elementIndex, 0, out var value)) + { + return input.TryEvaluateValue(value, out domain); + } + + domain = ShellValueDomain.Unknown; + return false; + } + + private bool TryGetElementValue( + SimpleCommandSyntax simple, + int elementIndex, + int valueOffset, + out ShellValue value) { var source = _factsFactory(simple); foreach (var provenance in source.ValueProvenance) { if (provenance.ClauseElementIndex == elementIndex) { - return input.TryEvaluateValue(provenance.Value, out domain); + if (valueOffset < 0 || valueOffset > provenance.Value.Decoded.Length) + { + value = ShellValue.Literal(string.Empty); + return false; + } + + value = valueOffset == 0 + ? provenance.Value + : provenance.Value.Slice(valueOffset); + return true; } } - domain = ShellValueDomain.Unknown; + value = ShellValue.Literal(string.Empty); return false; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs index a6e456b..11dfb04 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -94,6 +94,12 @@ internal sealed record PwshExecutionRegionBindingResult internal bool HasNoNewScope { get; init; } + internal int? WorkingDirectoryElementIndex { get; init; } + + internal int WorkingDirectoryValueOffset { get; init; } + + internal bool HasExplicitPSVersion { get; init; } + internal IReadOnlyList Bindings { get; init; } = Array.Empty(); } @@ -447,6 +453,14 @@ private static PwshExecutionRegionBindingResult BindReceiver( HasExplicitInputObject = arguments.HasNamed("InputObject"), HasNoNewScope = receiver == PwshExecutionRegionReceiver.InvokeCommand && arguments.IsSwitchEnabled("NoNewScope"), + WorkingDirectoryElementIndex = receiver == PwshExecutionRegionReceiver.StartJob + ? arguments.FirstNamedArgumentElementIndex("WorkingDirectory") + : null, + WorkingDirectoryValueOffset = receiver == PwshExecutionRegionReceiver.StartJob + ? arguments.FirstNamedArgumentValueOffset("WorkingDirectory") + : 0, + HasExplicitPSVersion = receiver == PwshExecutionRegionReceiver.StartJob && + arguments.HasNamed("PSVersion"), Bindings = bindings.OrderBy(binding => binding.HostClauseElementIndex).ToArray(), }; } @@ -850,7 +864,8 @@ private static BoundArguments BindArguments( { result.NamedArguments.Add(new BoundArgument( index, -1, true, resolution.CanonicalName, - HasTrailingComma(element), parameter.InlineValue!)); + HasTrailingComma(element), parameter.InlineValue!, + element.Value.Length - parameter.InlineValue!.Length)); if (HasTrailingComma(element) && !AcceptsScriptBlockArray(resolution.CanonicalName!)) { @@ -864,7 +879,8 @@ private static BoundArguments BindArguments( { result.NamedArguments.Add(new BoundArgument( index, -1, false, resolution.CanonicalName, false, - parameter.InlineValue!)); + parameter.InlineValue!, + element.Value.Length - parameter.InlineValue!.Length)); continue; } @@ -938,11 +954,11 @@ private static ParsedParameter ParseParameter(string value) return new ParsedParameter(value.Substring(1), false, false, true, false, null); } - var inlineValue = value.Substring(colon + 1).Trim(); + var inlineValue = value.Substring(colon + 1); return new ParsedParameter( value.Substring(1, colon - 1), inlineValue.Length > 0, - LooksLikeScriptBlock(inlineValue), + LooksLikeScriptBlock(inlineValue.Trim()), true, true, inlineValue); @@ -1549,7 +1565,8 @@ private readonly record struct BoundArgument( bool IsScriptBlock, string? ParameterName, bool HasTrailingComma, - string Value); + string Value, + int ValueOffset = 0); private sealed class BoundArguments { @@ -1605,6 +1622,38 @@ internal bool IsSwitchEnabled(string name) internal bool HasAnyNamed(params string[] names) => names.Any(HasNamed); + internal int? FirstNamedArgumentElementIndex(string parameterName) + { + foreach (var argument in NamedArguments) + { + if (string.Equals( + argument.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)) + { + return argument.ElementIndex; + } + } + + return null; + } + + internal int FirstNamedArgumentValueOffset(string parameterName) + { + foreach (var argument in NamedArguments) + { + if (string.Equals( + argument.ParameterName, + parameterName, + StringComparison.OrdinalIgnoreCase)) + { + return argument.ValueOffset; + } + } + + return 0; + } + internal int CountNamed(params string[] names) => names.Count(HasNamed); internal IEnumerable NamedScriptBlocks(string parameterName) => diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index 8df94d5..b358c7d 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -490,6 +490,62 @@ public void Start_job_binds_main_and_initialization_positions_independently() Assert.Equal(ExecutionRegionTiming.Concurrent, binding.Timing)); } + [Theory] + [InlineData("Start-Job -WorkingDirectory /tmp -ScriptBlock { Get-Date }", "/tmp")] + [InlineData("Start-Job -WorkingD /var/tmp -ScriptBlock { Get-Date }", "/var/tmp")] + [InlineData("Start-Job -WorkingDirectory:/opt -ScriptBlock { Get-Date }", "/opt")] + [InlineData( + "Start-Job -WorkingDirectory:' /tmp ' -ScriptBlock { Get-Date }", + " /tmp ")] + [InlineData( + "Start-Job -WorkingDirectory:\"x$HOME\" -ScriptBlock { Get-Date }", + "x$HOME")] + public void Start_job_retains_the_bound_working_directory_coordinate( + string source, + string expectedValue) + { + var clause = ParseClause(source); + var result = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true); + + var elementIndex = Assert.IsType(result.WorkingDirectoryElementIndex); + Assert.EndsWith(expectedValue, clause.Elements[elementIndex].Value); + Assert.Equal( + expectedValue, + clause.Elements[elementIndex].Value.Substring( + result.WorkingDirectoryValueOffset)); + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + } + + [Fact] + public void Non_job_receivers_do_not_publish_a_working_directory_coordinate() + { + var result = Bind("Invoke-Command -ScriptBlock { Get-Date }"); + + Assert.Null(result.WorkingDirectoryElementIndex); + Assert.Equal(0, result.WorkingDirectoryValueOffset); + } + + [Theory] + [InlineData("Start-Job -PSVersion 5.1 -ScriptBlock { Get-Date }")] + [InlineData("Start-Job -PSVersion:5.1 -ScriptBlock { Get-Date }")] + public void Start_job_retains_an_explicit_child_version_boundary(string source) + { + var result = Bind(source); + + Assert.True(result.HasExplicitPSVersion); + Assert.Equal(PwshExecutionRegionBindingStatus.ProvedExecution, result.Status); + } + + [Fact] + public void Default_start_job_does_not_invent_an_explicit_child_version() + { + var result = Bind("Start-Job -ScriptBlock { Get-Date }"); + + Assert.False(result.HasExplicitPSVersion); + } + [Fact] public void Named_primary_parameters_shift_positional_script_block_slots() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 0ef9b24..9ab57b6 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -179,6 +179,266 @@ public void In_process_invoke_command_joins_body_outcomes_before_host_continuati Assert.True(continuation.IsComplete); } + [Theory] + [InlineData("Start-Job -ScriptBlock { Get-Item child.txt }")] + [InlineData("sajb { Get-Item child.txt }")] + [InlineData("Microsoft.PowerShell.Core\\Start-Job { Get-Item child.txt }")] + public void Start_job_publishes_a_concurrent_once_main_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.Main, region.Phase); + Assert.Equal(ExecutionRegionTiming.Concurrent, 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 Start_job_keeps_authored_order_but_initializes_child_before_main() + { + var result = ParseIsolated( + "Start-Job -ScriptBlock { Measure-Command { Get-Date } } " + + "-InitializationScript { Set-Alias Measure-Command Write-Output }"); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + Assert.Equal( + new[] { ExecutionRegionPhase.Main, ExecutionRegionPhase.Initialization }, + host.ExecutionRegions.Select(region => region.Phase)); + Assert.Equal( + new[] { "Start-Job", "Measure-Command", "Get-Date", "Set-Alias" }, + result.Commands.Select(command => command.Clause.Verb.Tokens[0])); + var main = result.Commands[1]; + Assert.False(main.IsComplete); + } + + [Theory] + [InlineData("-WorkingDirectory /tmp")] + [InlineData("-WorkingD:/tmp")] + public void Start_job_applies_working_directory_before_initialization_and_isolates_exit( + string workingDirectoryArgument) + { + var result = ParseIsolated( + $"Start-Job {workingDirectoryArgument} " + + "-InitializationScript { Get-Item init.txt } " + + "-ScriptBlock { Get-Item main.txt; Set-Location / }; " + + "Get-Item host.txt"); + + var items = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Get-Item") + .ToArray(); + Assert.Equal(3, items.Length); + Assert.Equal("/tmp", Assert.Single(items[0].WorkingDirectory.Values)); + Assert.Equal("/tmp", Assert.Single(items[1].WorkingDirectory.Values)); + Assert.Equal("C:/work", Assert.Single(items[2].WorkingDirectory.Values)); + Assert.All(items, command => Assert.True(command.IsComplete)); + } + + [Theory] + [InlineData("-WorkingDirectory $target")] + [InlineData("-WorkingDirectory:$target")] + public void Start_job_accepts_a_bounded_host_working_directory_value( + string workingDirectoryArgument) + { + var result = ParseIsolated( + "foreach ($target in '/tmp') { }; " + + $"Start-Job {workingDirectoryArgument} " + + "-ScriptBlock { Get-Item child.txt }"); + + var child = Assert.Single( + result.Commands, + command => command.Clause.Verb.Tokens[0] == "Get-Item"); + Assert.Equal( + new[] { "/tmp" }, + child.WorkingDirectory.Values); + Assert.True(child.IsComplete); + } + + [Theory] + [InlineData("~/jobs", "C:/Users/test/jobs")] + [InlineData("$HOME/jobs", "C:/Users/test/jobs")] + public void Start_job_resolves_static_host_working_directory_forms( + string target, + string expected) + { + var result = ParseIsolated( + $"Start-Job -WorkingDirectory {target} " + + "-ScriptBlock { Get-Item child.txt }"); + + var child = result.Commands.Last(); + Assert.Equal( + new[] { expected }, + child.WorkingDirectory.Values); + Assert.True(child.IsComplete); + } + + [Theory] + [InlineData(".")] + [InlineData("..")] + [InlineData("jobs")] + [InlineData("child*")] + [InlineData("HKLM:/Software")] + [InlineData("\"x$HOME\"")] + [InlineData("\" $HOME \"")] + [InlineData("\"prefix${HOME}/x\"")] + public void Start_job_rejects_non_independently_rooted_working_directory_forms( + string target) + { + var result = ParseIsolated( + $"Start-Job -WorkingDirectory {target} " + + "-ScriptBlock { Get-Item child.txt }"); + + var child = result.Commands.Last(); + Assert.Equal(ShellValueDomainKind.Unknown, child.WorkingDirectory.Kind); + } + + [Fact] + public void Start_job_preserves_significant_inline_working_directory_whitespace() + { + var result = ParseIsolated( + "Start-Job -WorkingDirectory:' /tmp ' " + + "-ScriptBlock { Get-Item child.txt }"); + + var child = result.Commands.Last(); + Assert.Equal(ShellValueDomainKind.Unknown, child.WorkingDirectory.Kind); + Assert.True(child.IsComplete); + } + + [Theory] + [InlineData("-PSVersion 5.1")] + [InlineData("-PSVersion:5.1")] + public void Start_job_alternate_child_version_remains_visible_but_incomplete( + string versionArgument) + { + var result = ParseIsolated( + $"Start-Job {versionArgument} -ScriptBlock {{ Get-Item child.txt }}"); + + var host = Assert.IsType(Assert.Single(result.Syntax.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)); + } + + [Theory] + [InlineData( + "Start-Job -PSVersion 5.1 -ScriptBlock { Set-Location / }")] + [InlineData( + "Start-Job -FilePath script.ps1 " + + "-InitializationScript { Set-Location / }")] + [InlineData( + "Start-Job -RunAs32 -ScriptBlock { Set-Location / }")] + public void Incomplete_start_job_variants_preserve_host_location_boundary( + string invocation) + { + var result = ParseIsolated(invocation + "; Get-Item host.txt"); + + var continuation = result.Commands.Last(); + Assert.Equal("Get-Item", continuation.Clause.Verb.Tokens[0]); + Assert.Equal( + new[] { "C:/work" }, + continuation.WorkingDirectory.Values); + Assert.True(continuation.IsComplete); + } + + [Theory] + [InlineData( + "Start-Job -PSVersion 5.1 -ScriptBlock { " + + "Set-Alias Measure-Command Write-Output }")] + [InlineData( + "Start-Job -FilePath script.ps1 -InitializationScript { " + + "Set-Alias Measure-Command Write-Output }")] + [InlineData( + "Start-Job -RunAs32 -ScriptBlock { " + + "Set-Alias Measure-Command Write-Output }")] + public void Incomplete_start_job_variants_preserve_host_command_resolution_boundary( + string invocation) + { + var result = ParseIsolated(invocation + "; Measure-Command { Get-Date }"); + + var continuation = result.Commands + .Last(command => command.Clause.Verb.Tokens[0] == "Measure-Command"); + Assert.True(continuation.IsComplete); + } + + [Fact] + public void Start_job_does_not_inherit_host_bindings_or_export_child_bindings() + { + var result = ParseIsolated( + "foreach ($x in 'outer') { }; Start-Job " + + "-InitializationScript { foreach ($x in 'init') { }; Write-Output $x } " + + "-ScriptBlock { Write-Output $x; foreach ($x in 'main') { } }; " + + "Write-Output $x"); + + var writes = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Write-Output") + .ToArray(); + Assert.Equal(3, writes.Length); + Assert.All(writes.Take(2), write => + { + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(write.EffectiveArguments).Value.Kind); + Assert.True(write.IsComplete); + }); + Assert.Equal( + new[] { "outer" }, + Assert.Single(writes[2].EffectiveArguments).Value.Values); + Assert.True(writes[2].IsComplete); + } + + [Fact] + public void Start_job_initialization_mutation_invalidates_main_but_not_host_resolution() + { + var result = ParseIsolated( + "Start-Job -InitializationScript { " + + "Set-Alias Measure-Command Write-Output } -ScriptBlock { " + + "Measure-Command { Get-Date } }; Measure-Command { Get-Date }"); + + var measurements = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Measure-Command") + .ToArray(); + Assert.Equal(2, measurements.Length); + Assert.False(measurements[0].IsComplete); + Assert.True(measurements[1].IsComplete); + } + + [Fact] + public void Dynamic_start_job_working_directory_fails_closed_only_in_the_child() + { + var result = ParseIsolated( + "Start-Job -WorkingDirectory $target " + + "-ScriptBlock { Get-Item child.txt }; Get-Item host.txt"); + + var items = result.Commands + .Where(command => command.Clause.Verb.Tokens[0] == "Get-Item") + .ToArray(); + Assert.Equal(2, items.Length); + Assert.Equal(ShellValueDomainKind.Unknown, items[0].WorkingDirectory.Kind); + Assert.True(items[0].IsComplete); + Assert.Equal( + new[] { "C:/work" }, + items[1].WorkingDirectory.Values); + Assert.True(items[1].IsComplete); + } + + [Fact] + public void Start_job_file_path_keeps_initialization_visible_but_incomplete() + { + var result = ParseIsolated( + "Start-Job -FilePath script.ps1 " + + "-InitializationScript { Get-Date }"); + + var host = Assert.IsType(Assert.Single(result.Syntax.Statements)); + var initialization = Assert.Single(host.ExecutionRegions); + Assert.Equal(ExecutionRegionPhase.Unknown, initialization.Phase); + Assert.All(result.Commands, command => Assert.False(command.IsComplete)); + } + [Theory] [InlineData("New-Module { Get-Item child.txt }")] [InlineData("nmo -ScriptBlock { Get-Item child.txt }")] diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs index 3c096ba..d806806 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ShellValueOracleTests.cs @@ -1176,6 +1176,158 @@ public void PowerShell_synchronous_regions_observe_scope_and_pipeline_stage_effe Lines(output)); } + [Fact] + public void PowerShell_start_job_initializes_child_before_main_and_isolates_exit() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$start=(Get-Location).Path; " + + "$target=[IO.Path]::TrimEndingDirectorySeparator(" + + "(Resolve-Path ([IO.Path]::GetTempPath())).Path); " + + "$x='outer'; $job=Start-Job -WorkingDirectory $target " + + "-InitializationScript { " + + "\"init-cwd-target=<$((Get-Location).Path -eq " + + "[IO.Path]::TrimEndingDirectorySeparator(" + + "(Resolve-Path ([IO.Path]::GetTempPath())).Path))>\"; $x='init' } " + + "-ScriptBlock { \"main-x=<$x>\"; " + + "\"main-cwd-target=<$((Get-Location).Path -eq " + + "[IO.Path]::TrimEndingDirectorySeparator(" + + "(Resolve-Path ([IO.Path]::GetTempPath())).Path))>\"; " + + "$x='main'; Set-Location ([IO.Path]::GetPathRoot((Get-Location).Path)) }; " + + "Receive-Job -Job $job -Wait; Remove-Job -Job $job; " + + "\"host-x=<$x>\"; " + + "\"host-cwd-start=<$((Get-Location).Path -eq $start)>\""); + + Assert.Equal( + new[] + { + "init-cwd-target=", + "main-x=", + "main-cwd-target=", + "host-x=", + "host-cwd-start=", + }, + Lines(output)); + } + + [Fact] + public void PowerShell_start_job_preserves_inline_working_directory_whitespace() + { + if (!IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$target=' sst-inline-space-path-91f62f1f '; " + + "if (Test-Path -LiteralPath $target) { throw 'path collision' }; " + + "try { Start-Job -WorkingDirectory:' sst-inline-space-path-91f62f1f ' " + + "-ScriptBlock { Get-Date } -ErrorAction Stop } " + + "catch { \"path-preserved=<$($_.Exception.Message.Contains($target))>\" }"); + + Assert.Equal("path-preserved=", output); + } + + [Fact] + public void PowerShell_start_job_prefixed_home_expansion_remains_relative() + { + if (OperatingSystem.IsWindows() || !IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$target=\"x$HOME\"; " + + "if (Test-Path -LiteralPath $target) { throw 'path collision' }; " + + "try { Start-Job -WorkingDirectory:\"x$HOME\" " + + "-ScriptBlock { Get-Date } -ErrorAction Stop } " + + "catch { \"path-preserved=<$($_.Exception.Message.Contains($target))>\" }"); + + Assert.Equal("path-preserved=", output); + } + + [Fact] + public void PowerShell_start_job_relative_working_directory_uses_child_startup_base() + { + if (OperatingSystem.IsWindows() || !IsAvailable("pwsh")) + { + return; + } + + var workingDirectory = Path.Combine( + Path.GetTempPath(), + "shellsyntaxtree-oracle-" + Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(workingDirectory); + try + { + var output = RunInWorkingDirectory( + "pwsh", + workingDirectory, + "-NoProfile", + "-NonInteractive", + "-Command", + "$start=(Get-Location).Path; $homePath=(Resolve-Path $HOME).Path; " + + "$dot=Start-Job -WorkingDirectory . -ScriptBlock { " + + "(Get-Location).Path }; $dotPath=Receive-Job $dot -Wait; " + + "Remove-Job $dot; $parent=Start-Job -WorkingDirectory .. " + + "-ScriptBlock { (Get-Location).Path }; " + + "$parentPath=Receive-Job $parent -Wait; Remove-Job $parent; " + + "\"caller-distinct=<$($dotPath -ne $start)>\"; " + + "\"dot-home=<$($dotPath -eq $homePath)>\"; " + + "\"parent-home-parent=<$($parentPath -eq " + + "(Split-Path $homePath -Parent))>\""); + + Assert.Equal( + new[] + { + "caller-distinct=", + "dot-home=", + "parent-home-parent=", + }, + Lines(output)); + } + finally + { + Directory.Delete(workingDirectory, recursive: true); + } + } + + [Fact] + public void Windows_start_job_psversion_selects_windows_powershell_51() + { + if (!OperatingSystem.IsWindows() || !IsAvailable("pwsh")) + { + return; + } + + var output = Run( + "pwsh", + "-NoProfile", + "-NonInteractive", + "-Command", + "$job=Start-Job -PSVersion 5.1 -ScriptBlock { " + + "$PSVersionTable.PSVersion.ToString() }; " + + "Receive-Job -Job $job -Wait; Remove-Job -Job $job"); + + Assert.StartsWith("5.1.", output, StringComparison.Ordinal); + } + private static bool IsAvailable(string executable) { try