From 1a789f4b7495b893ff6688547dc05d8f0998a200 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 14:18:48 +0000 Subject: [PATCH] fix: complete stable PowerShell design cases --- IMPLEMENTATION_PLAN.md | 28 +-- .../v0-3-structured-shell-analysis/tasks.md | 30 ++-- .../Parsing/PwshForEachStructuralParser.cs | 7 +- .../Pwsh/Parsing/PwshForEachValueAnalysis.cs | 26 ++- .../PwshExecutionRegionBindingCatalog.cs | 16 +- .../355_v03_foreach_body_mutation_gated.json | 2 +- .../356_v03_foreach_alias_mutation_gated.json | 2 +- ...oreach_iterator_module_mutation_gated.json | 2 +- ...8_v03_foreach_provider_mutation_gated.json | 2 +- ...3_foreach_quoted_alias_mutation_gated.json | 2 +- ...ach_quoted_environment_mutation_gated.json | 2 +- ...der_qualified_iterator_mutation_gated.json | 2 +- ...sign_foreach_dynamic_command_identity.json | 164 ++++++++++++++++++ ...and_local_asjob_invalid_parameter_set.json | 11 ++ .../DesignCorpus/v0.3/bash.json | 2 + .../DesignCorpus/v0.3/powershell.json | 2 + .../PwshExecutionRegionBindingCatalogTests.cs | 15 +- .../PwshExecutionRegionStructuralTests.cs | 13 +- .../Parsing/PwshForEachStructuralTests.cs | 38 +++- tools/PwshCorpusTool/CorpusManifest.cs | 6 + 20 files changed, 322 insertions(+), 50 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/539_v03_design_foreach_dynamic_command_identity.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/540_v03_design_invoke_command_local_asjob_invalid_parameter_set.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index bdba5e4..34fad25 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -299,8 +299,8 @@ priorities. Promotion also reconciled the unquoted wildcard redirect story with the fail-closed completeness contract, publishes sparse exact/unknown effective-value overlays, and pins quoted, escaped, - and continued tilde-prefix behavior against Bash. The PowerShell promotion - half keeps OpenSpec task 1.10 open. + and continued tilde-prefix behavior against Bash. The later PowerShell + execution-region and loop slices now complete OpenSpec task 1.10. - [x] Promote the first 22 stable PowerShell design cases covering value, path, and redirect provenance. Exact effective values are published only when parser-owned fragments prove the post-lexical value; runtime @@ -331,8 +331,8 @@ priorities. the explicit constrained command-resolution baseline, including after decoded-host boundaries. The v0.3 authored-command approval correction above supersedes that behavior and is implemented in this slice. - Execution-region and loop/state design promotions remain, so OpenSpec - task 1.10 stays open. + Execution-region and loop/state design promotions remained for later + slices; the final promotion below now closes OpenSpec task 1.10. - [x] Deliver the first Bash `$()` substitution slice for supported simple-command arguments and redirect targets. Direct tests and corpus entries pin multiple and nested ordering, exact ancestry/spans, isolated @@ -503,10 +503,11 @@ priorities. parameters, positional slots, parameter sets, `ScriptBlock[]`, authored ForEach-Object multi-block coordinates, and semantic Begin/Process/End phases. The optional Microsoft.PowerShell.ThreadJob entry remains an - unknown incomplete receiver and no longer gates stable v0.3. Local - `Invoke-Command -AsJob`, ambiguous prefixes, malformed value binding, - unproved identities, and unknown receivers retain unknown/incomplete - facts. Supported catalog-owned module qualifications now pass structural + unknown incomplete receiver and no longer gates stable v0.3. A proved + local `Invoke-Command -AsJob` combination now fails atomically because + PowerShell has no compatible in-process parameter set. Ambiguous prefixes, + malformed value binding, unproved identities, and unknown receivers retain + unknown/incomplete facts. Supported catalog-owned module qualifications now pass structural admission because every possible body remains visible; the occurrence analyzer still withholds typed receiver facts after an observed command- resolution mutation unless bounded mutation provenance proves the exact @@ -574,12 +575,13 @@ priorities. authored `Set-Alias Env:...` invocation. The generator-owned executable corpus now supports per-entry initial- state mode and includes the promoted Parallel and remote/session cases. - It now contains exact generated expectations for 84 of the 90 PowerShell + It now contains exact generated expectations for 86 of the 90 PowerShell design cases. Four intentionally deferred condition/deferred-action cases - remain parked. Two stable cases remain implementation work rather than - being mislabeled as corpus-complete: bounded-loop dynamic invocation - still fails atomically, and local `Invoke-Command -AsJob` still needs to - reject its invalid parameter-set combination. Direct generated cases for + remain parked. Bounded-loop dynamic invocation is visible and incomplete, + retains finite loop-variable arguments for the occurrence, and invalidates + following state. A proved local `Invoke-Command -AsJob` combination fails + atomically as an invalid parameter set. Generated entries 539-540 pin + both corrections. Direct generated cases for `Measure-Command`, `Trace-Command`, and `ForEach-Object -RemainingScripts` fill the remaining stable receiver-catalog evidence without renumbering the existing corpus. diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index c6b56c5..9fea6dd 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -9,13 +9,13 @@ - [x] 1.7 Synchronize PowerShell grammar and analysis deltas into `SPEC.POWERSHELL.md`. - [x] 1.8 Update `PROJECT_CONTEXT.md` and `IMPLEMENTATION_PLAN.md` with the accepted v0.3 scope and delivery slices. - [x] 1.9 Add a paired Bash and PowerShell design corpus that records current behavior, desired structure, command occurrences, bounded values, redirect facts, compatibility projections, and security invariants. -- [ ] 1.10 Promote every design case for a stable-v0.3 construct into the +- [x] 1.10 Promote every design case for a stable-v0.3 construct into the executable corpus as its production parser slice lands. Retain future-scope design cases as non-gating evidence rather than release work. - - PowerShell now has exact generated expectations for 84 of 90 design cases. - Four future-scope cases remain non-gating. The stable bounded-loop dynamic - invocation and invalid local `Invoke-Command -AsJob` cases remain pending - production corrections and are not marked as promoted. + - Bash has exact executable expectations for 46 of 49 design cases; its + condition-loop, branch, and process-substitution cases remain future scope. + PowerShell has exact generated expectations for 86 of 90 design cases; its + condition-loop, branch, and deferred-action cases remain future scope. - [x] 1.11 Correct the PowerShell script-block boundary and lock the additive execution-region node, origin/phase/timing/cardinality facts, authored-versus-semantic ordering, command projection, and independent shell-state analysis contract @@ -185,8 +185,10 @@ occurrences until tasks 7.3 and 7.4 added binding and runspace analysis. Alpha.3 still leaves default-mode occurrences incomplete for ambient resolution; task 7.2c corrects that behavior. Explicit iterator/body state - mutation and dynamic invocation remain strict, and isolated child-host - loops do not taint their outer continuation. + mutation remains strict. Dynamic command identities are visible and + incomplete, preserve bounded loop-variable values for that occurrence, + and invalidate following state proofs. Isolated child-host loops do not + taint their outer continuation. - [x] 7.2a Add the explicit `PwshInitialStateMode` contract and safe default before value analysis. Lock the constrained noninteractive no-profile host and module baseline, current-runspace sharing, child-host noninheritance, @@ -271,13 +273,17 @@ - [x] 7.5g Retain explicit atomic-failure behavior for direct-block arguments and leading `param(...)` declarations. Declaration and argument-binding grammar is not required for stable v0.3. -- [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow. +- [x] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow. + - Direct and generated corpus cases pin unknown pipeline-object values, + provider and variable mutation, incomplete dynamic identities, opaque + splats, the 32-candidate boundary, and overflow-to-Unknown behavior. - [ ] 7.7 Add PowerShell corpus entries, live `pwsh` oracle coverage, and Netclaw integration cases. - `PwshCorpusTool` now supports case-specific `PwshInitialStateMode`; keep - promoting the two remaining stable cases into its generated manifest after - their production corrections, then complete the Netclaw PowerShell policy - matrix. Exact generated cases now cover `Measure-Command`, `Trace-Command`, - and `ForEach-Object -RemainingScripts` directly. + the generated 540-case manifest and live-oracle matrix aligned, then + complete the Netclaw PowerShell policy matrix. Exact generated cases now + cover the final stable dynamic-loop and invalid local `Invoke-Command + -AsJob` cases, plus `Measure-Command`, `Trace-Command`, and + `ForEach-Object -RemainingScripts` directly. - [x] 7.8 Implement the additive `PwshDialect` API, PowerShell 7 compatibility default, unknown-value safe-fail, Windows PowerShell 5.1 pipeline-chain rejection, dialect-specific alias and execution-region metadata, and static diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs index fe1381e..96e5f91 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs @@ -115,7 +115,7 @@ private bool TryParseForEach( if (ContainsUnsupportedForEachStateTransfer(iteratorCommands) || ContainsUnsupportedForEachStateTransfer(body)) { - error = "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice"; + error = "PowerShell foreach state mutation is not supported in this structural slice"; return false; } @@ -416,11 +416,6 @@ private static bool ContainsUnsupportedForEachStateTransfer(ShellSyntaxNode node { foreach (var clause in EnumerateClauses(node)) { - if (clause.Verb.IsDynamic) - { - return true; - } - var verb = clause.Verb.CanonicalVerb ?? (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); if (verb is not null && diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 68b1153..449f693 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -2271,6 +2271,12 @@ private PwshFlowResult ApplyExecutionRegionEffect( simple.Clause, receiverIdentityProven, _options.Dialect); + if (binding.Status == PwshExecutionRegionBindingStatus.InvalidParameterSet) + { + _isComplete = false; + return new PwshFlowResult(null, null); + } + if (binding.Status == PwshExecutionRegionBindingStatus.ProvedData) { RecordExecutionRegions( @@ -3645,7 +3651,10 @@ private PwshFlowResult AnalyzeForEach(ForEachSyntax forEach, AnalysisContext inp var body = AnalyzeBlock( forEach.Body, - iterationInput.WithBinding(plan.BindingName, candidate)); + iterationInput.WithBinding( + plan.BindingName, + candidate, + loopInput.CanPromote)); if (body.JoinedState is not AnalysisContext bodyExit) { return new PwshFlowResult(null, null); @@ -3679,7 +3688,10 @@ private PwshFlowResult AnalyzeForEachFixedPoint( var body = AnalyzeBlock( forEach.Body, - head.WithBinding(plan.BindingName, plan.Summary)); + head.WithBinding( + plan.BindingName, + plan.Summary, + loopInput.CanPromote)); if (body.JoinedState is not AnalysisContext bodyExit) { return new PwshFlowResult(null, null); @@ -3706,7 +3718,10 @@ private PwshFlowResult AnalyzeForEachFixedPoint( var widened = AnalysisContext.Widen(wideningBase, nextHead); var widenedBody = AnalyzeBlock( forEach.Body, - widened.WithBinding(plan.BindingName, plan.Summary)); + widened.WithBinding( + plan.BindingName, + plan.Summary, + loopInput.CanPromote)); exits = AnalysisContext.JoinNullable(exits, widenedBody.JoinedState); return exits is AnalysisContext widenedExit ? PwshFlowResult.Both(widenedExit) @@ -5625,7 +5640,8 @@ internal AnalysisContext WithCwd(string? workingDirectory) => internal AnalysisContext WithBinding( string name, - ShellValueDomain domain) + ShellValueDomain domain, + bool canPromote) { var bindings = new List(_bindings.Count + 1); foreach (var binding in _bindings) @@ -5638,7 +5654,7 @@ internal AnalysisContext WithBinding( bindings.Add(new BindingFrame( name, - CanPromote ? domain : ShellValueDomain.Unknown)); + canPromote ? domain : ShellValueDomain.Unknown)); return new AnalysisContext( WorkingDirectory, CanPromote, diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs index 7cbd7e2..e313b1d 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -15,6 +15,7 @@ internal enum PwshExecutionRegionBindingStatus NotApplicable, ProvedData, ProvedExecution, + InvalidParameterSet, Ambiguous, } @@ -366,12 +367,23 @@ private static PwshExecutionRegionBindingResult BindReceiver( if (arguments.HasAmbiguousScriptBlockBinding || arguments.HasDuplicateParameter || arguments.HasInvalidScalarScriptBlockArray - || HasReceiverValidationConflict(receiver, arguments) - || compatibleSets.Count == 0) + || HasReceiverValidationConflict(receiver, arguments)) { return Ambiguous(canonicalName, receiver, scriptBlocks); } + if (compatibleSets.Count == 0) + { + var result = Ambiguous(canonicalName, receiver, scriptBlocks); + return receiver == PwshExecutionRegionReceiver.InvokeCommand && + arguments.HasNamed("AsJob") + ? result with + { + Status = PwshExecutionRegionBindingStatus.InvalidParameterSet, + } + : result; + } + var bindings = new List(); var parameterSet = PwshExecutionRegionParameterSet.Unknown; switch (receiver) diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json index 8bba7f6..7b76d88 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/355_v03_foreach_body_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in 1) { Set-Variable x 2 }", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Body mutation fails closed until PowerShell abstract state is modeled.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json index d3aeb03..819b532 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/356_v03_foreach_alias_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in 1) { Set-Alias wipe Remove-Item }; wipe file.txt", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Command-resolution mutation cannot leave a post-loop command marked complete.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json index c36e9f4..970799c 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/357_v03_foreach_iterator_module_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in Import-Module ./commands.psm1) { Write-Output $x }; Invoke-Thing", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Iterator module mutation fails closed before later command resolution can be trusted.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json index 4130b56..2c74972 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/358_v03_foreach_provider_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in 1) { Set-Item Alias:wipe Remove-Item }; wipe file.txt", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Alias, function, variable, and environment provider writes are runspace mutation.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json index b8408d8..4dc410a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/359_v03_foreach_quoted_alias_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in 1) { Set-Item \u0027Alias:wipe\u0027 Remove-Item }; wipe file.txt", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Quoted provider paths use decoded provenance and cannot bypass mutation gating.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json index fdda1bf..50556d1 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/360_v03_foreach_quoted_environment_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in 1) { Set-Item \u0022Env:PATH\u0022 C:\\tools }; tool", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "A quoted environment provider write can change later command resolution.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json index d5f0fe7..4ed1242 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/361_v03_foreach_provider_qualified_iterator_mutation_gated.json @@ -3,7 +3,7 @@ "input": "foreach ($x in $(Set-Item \u0027Microsoft.PowerShell.Core\\Alias::wipe\u0027 Remove-Item; wipe victim)) { }", "expected": { "isUnparseable": true, - "unparseableReasonContains": "PowerShell foreach state mutation or dynamic invocation is not supported in this structural slice" + "unparseableReasonContains": "PowerShell foreach state mutation is not supported in this structural slice" }, "notes": "Provider-qualified iterator mutation cannot publish a later alias invocation as complete.", "oracleExpectation": "OutOfScope" diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/539_v03_design_foreach_dynamic_command_identity.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/539_v03_design_foreach_dynamic_command_identity.json new file mode 100644 index 0000000..30b2524 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/539_v03_design_foreach_dynamic_command_identity.json @@ -0,0 +1,164 @@ +{ + "name": "V03 design foreach dynamic command identity", + "input": "foreach ($f in @(\u0027a\u0027, \u0027b\u0027)) { \u0026 $exe $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "$exe" + ], + "isDynamic": true, + "args": [ + { + "raw": "$f", + "kind": "EnvVar", + "isPath": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "$exe", + "value": "$exe", + "role": "Verb", + "sourceStart": 32, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + }, + { + "raw": "$f", + "value": "$f", + "role": "Argument", + "sourceStart": 37, + "sourceLength": 2, + "precedingVerbElementCount": 1, + "kind": "EnvVar", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 41, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 41, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027a\u0027, \u0027b\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 11 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 29, + "sourceLength": 11, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 30, + "sourceLength": 9, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 41 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 41 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 29, + "sourceLength": 11 + } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "FiniteSet", + "values": [ + "a", + "b" + ], + "pattern": null, + "coveringDirectory": null + } + } + ], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Promotes stable design case pwsh-foreach-dynamic-command-identity." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/540_v03_design_invoke_command_local_asjob_invalid_parameter_set.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/540_v03_design_invoke_command_local_asjob_invalid_parameter_set.json new file mode 100644 index 0000000..5ea97c6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/540_v03_design_invoke_command_local_asjob_invalid_parameter_set.json @@ -0,0 +1,11 @@ +{ + "name": "V03 design invoke command local asjob invalid parameter set", + "input": "Invoke-Command -ScriptBlock { Get-Date } -AsJob", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell structural syntax exceeded limits or contained invalid parser-owned facts" + }, + "notes": "Promotes stable fail-closed design case pwsh-invoke-command-local-asjob-invalid-parameter-set.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json index 204ceb0..f6e43e8 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/bash.json @@ -652,6 +652,7 @@ }, { "id": "bash-contextual-for-argument", + "compatibilityProjectionLanded": true, "concern": "Control keyword outside command position", "input": "echo for", "current": { "isUnparseable": false }, @@ -766,6 +767,7 @@ }, { "id": "bash-dynamic-fd-target", + "compatibilityProjectionLanded": true, "concern": "Computed descriptor target remains unknown", "input": "command 2>&$FD", "current": { "isUnparseable": false }, diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index 21328b0..5f2e947 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -581,6 +581,7 @@ { "id": "pwsh-foreach-dynamic-command-identity", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, "concern": "Dynamic invocation in a bounded loop body", "input": "foreach ($f in @('a', 'b')) { & $exe $f }", "current": { "isUnparseable": true, "reasonContains": "dynamic invocation" }, @@ -2462,6 +2463,7 @@ }, { "id": "pwsh-invoke-command-local-asjob-invalid-parameter-set", + "compatibilityProjectionLanded": true, "concern": "In-process Invoke-Command never acquires invented concurrent semantics from an invalid AsJob combination", "input": "Invoke-Command -ScriptBlock { Get-Date } -AsJob", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index 22f55a5..fe32bbc 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -608,11 +608,20 @@ public void Ssh_transport_true_value_preserves_remote_binding() } [Fact] - public void As_job_without_remote_target_does_not_invent_local_job_semantics() + public void As_job_without_remote_target_is_an_invalid_parameter_set() { - var result = Bind("Invoke-Command -AsJob -ScriptBlock { Get-Date }"); + var parsed = IsolatedParser.Parse( + "Set-Alias Invoke-Command Write-Output; " + + "Invoke-Command -AsJob -ScriptBlock { Get-Date }"); + Assert.False(parsed.IsUnparseable, parsed.UnparseableReason); + var clause = parsed.Clauses.Last(candidate => candidate.Elements.Any(element => + element.Kind == ArgKind.DynamicSkip && element.Raw.Contains('{'))); + var result = PwshExecutionRegionBindingCatalog.Bind( + clause, + commandIdentityProven: true, + dialect: PwshDialect.PowerShell7); - Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); + Assert.Equal(PwshExecutionRegionBindingStatus.InvalidParameterSet, result.Status); Assert.False(Assert.Single(result.Bindings).IsComplete); } diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs index 9f4d9ce..8f52598 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionStructuralTests.cs @@ -112,8 +112,19 @@ public void In_process_invoke_command_explicit_false_no_new_scope_isolates_state Assert.True(continuation.IsComplete); } + [Fact] + public void Invalid_local_invoke_command_as_job_fails_atomically() + { + var result = ParseIsolatedRaw( + "Invoke-Command -ScriptBlock { Get-Date } -AsJob"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.NotEmpty(result.Syntax.Statements); + } + [Theory] - [InlineData("Invoke-Command -AsJob -ScriptBlock { Get-Date }")] [InlineData("Invoke-Command -NoNewScope:$scope -ScriptBlock { Get-Date }")] public void Unproved_invoke_command_shapes_remain_unknown(string source) { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index 04fcf2c..4741fd3 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -171,7 +171,6 @@ public void Decoded_host_wrapper_retains_foreach_structure_without_outer_spans() [InlineData("foreach ($x in) { Write-Output $x }")] [InlineData("foreach ($x in 1) Write-Output $x")] [InlineData("foreach ($x in 1) { Write-Output $x")] - [InlineData("foreach ($x in 1) { & $command $x }")] public void Dynamic_or_malformed_foreach_fails_atomically(string source) { var result = Parse(source); @@ -1005,6 +1004,43 @@ public void Dynamic_invoke_expression_inside_foreach_fails_atomically() Assert.Contains("state mutation", result.UnparseableReason!); } + [Fact] + public void Dynamic_command_identity_in_bounded_foreach_is_visible_and_invalidates_state() + { + var result = ParseIsolated( + "foreach ($f in @('a', 'b')) { & $exe $f }; Get-Item child.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + var dynamic = result.Commands[0]; + Assert.Equal("$exe", CommandVerb(dynamic)); + Assert.False(dynamic.IsComplete); + AssertDomain( + Assert.Single(dynamic.EffectiveArguments).Value, + ShellValueDomainKind.FiniteSet, + "a", + "b"); + var continuation = result.Commands[1]; + Assert.False(continuation.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, continuation.WorkingDirectory.Kind); + } + + [Fact] + public void Dynamic_command_before_bounded_foreach_prevents_binding_promotion() + { + var result = ParseIsolated( + "& $exe preflight; " + + "foreach ($f in @('a', 'b')) { Write-Output $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var body = result.Commands.Last(); + Assert.Equal("Write-Output", CommandVerb(body)); + Assert.False(body.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(body.EffectiveArguments).Value.Kind); + } + [Fact] public void Nonfilesystem_location_failure_partition_retains_incoming_state() { diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 89468b3..2e3cf04 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -1524,5 +1524,11 @@ private static string NestIex(string inner, int depth) "ForEach-Object -RemainingScripts { Write-Output first }, " + "{ Write-Output second } -Process { Write-Output third }", "Pins authored ordering and exact-once ownership for the stable RemainingScripts binding."), + VIE("v03_design_foreach_dynamic_command_identity", + "foreach ($f in @('a', 'b')) { & $exe $f }", + "Promotes stable design case pwsh-foreach-dynamic-command-identity."), + OosI("v03_design_invoke_command_local_asjob_invalid_parameter_set", + "Invoke-Command -ScriptBlock { Get-Date } -AsJob", + "Promotes stable fail-closed design case pwsh-invoke-command-local-asjob-invalid-parameter-set."), }; }