diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 710e596..135a1bf 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -240,9 +240,15 @@ priorities.
`Clause` identity; unsupported wrapper tails and depth overflow fail
closed. Redirect-bearing leaves remain incomplete until the explicit
redirect-analysis slice lands.
-- [ ] Adapt the existing PowerShell grammar to emit the structural and
+- [x] Adapt the existing PowerShell grammar to emit the structural and
command-occurrence projections before enabling any control-flow
- construct.
+ construct. The PowerShell-specific recursive coordinator preserves
+ statement/pipeline precedence, current-scope parenthesized groups,
+ isolated child-host wrappers, current-scope `Invoke-Expression`, exact
+ direct spans, nullable decoded spans, compatibility operators, and
+ shared leaf identity. It rejects hostile structural depth before descent
+ and leaves redirects, dynamic identities, unproved host command strings,
+ and undiscovered executable expressions incomplete.
- [ ] Deliver paired Bash and PowerShell `$()` substitution slices for all
locked executable value positions, including ordering, ancestry,
shell-specific cwd propagation, literal/escaped boundaries, dynamic
@@ -266,6 +272,9 @@ priorities.
- Seed corpus entries from sanitized real-world dogfood logs (SPEC §14
workflow) — both shells.
+- Synchronize PowerShell corpus manifest entries 282–309 before using the
+ generator as an all-corpus rewrite; until then, targeted manifest changes
+ must preserve the checked-in hand-authored tail.
- Expand verb / cmdlet / alias tables as the corpus surfaces real commands.
- Performance sanity check (~1 ms typical) with a tiny BenchmarkDotNet
harness — only if anything in the daemon hot path complains.
diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md
index 9abfda9..c62736e 100644
--- a/SPEC.POWERSHELL.md
+++ b/SPEC.POWERSHELL.md
@@ -325,6 +325,11 @@ quoted_string := single_quoted | double_quoted
arguments is a syntax error and makes the whole result unparseable.
- A parenthesized **pipeline** `( ... )` parses as a grouped sub-pipeline;
its clauses carry `IsSubshell = true` as a *structural* marker only.
+ PowerShell permits that grouped expression only as the first pipeline
+ element; `Get-Date | (Get-Process)` is unparseable rather than a second
+ grouped stage. The group body is one pipeline, so statement separators such
+ as `(Get-Date; Get-Process)` are also unparseable. Leading and trailing
+ newlines inside the delimiters collapse.
Unlike a bash subshell, PowerShell's `( ... )` is a grouping operator — it
creates **no scope and no working-directory boundary** (`$PWD` is runspace
state, not a scoped variable). `Set-Location` attribution therefore
@@ -1100,6 +1105,15 @@ inner clause's `Redirects` and `Elements`; its outer source span remains exact.
Non-redirect arguments after a quoted, script-block, colon-bound, or encoded
payload are not modeled and set `IsUnparseable=true` rather than disappearing.
+When the complete command-string production is not proved, the parser retains
+the authored outer host clause but sets its command occurrence
+`IsComplete=false`. This includes dynamic or quoted wrapper-control input,
+`--%`, stdin-driven `-Command -`, and command-string-capable forms outside the
+locked grammar such as `-CommandWithArgs` / `-cwa`. The same incomplete-outer
+rule applies to a computed `Invoke-Expression` payload. These leaves preserve
+compatibility evidence; they are never sufficient authorization evidence and
+must not be mistaken for proof that no hidden command can execute.
+
`pwsh -File script.ps1` is **not** recursion — the file content is not
available to the parser. It parses as an ordinary clause with `script.ps1`
as a path arg.
diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
index 5a13cb9..3915855 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md
@@ -73,6 +73,16 @@ partial command and compatibility result.
- **THEN** the existing outer compatibility leaf remains visible with direct source provenance
- **THEN** its command occurrence has `IsComplete=false` because no hidden command-string body was discovered
+#### Scenario: Unproved PowerShell command string remains incomplete
+- **WHEN** PowerShell parses a `pwsh` or `powershell` host whose command-string control is dynamic, quoted, hidden behind `--%`, stdin-driven by `-Command -`, or uses an unsupported command-string-capable form such as `-CommandWithArgs` / `-cwa`
+- **THEN** the existing outer compatibility leaf remains visible with direct source provenance
+- **THEN** its command occurrence has `IsComplete=false` because no complete executable body was discovered
+
+#### Scenario: Computed Invoke-Expression remains incomplete
+- **WHEN** PowerShell retains an outer `Invoke-Expression` clause because its payload is computed rather than one exact static scalar
+- **THEN** the payload remains an authored `DynamicSkip` value
+- **THEN** the command occurrence has `IsComplete=false` and cannot authorize hidden code
+
#### Scenario: While condition and body roles
- **WHEN** Bash parses `while curl URL; do sleep 1; done`
- **THEN** `curl` is identified as a condition occurrence
diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
index c243947..5615170 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
@@ -28,8 +28,8 @@
- [x] 3.4 Build a library-owned traversal that emits each simple command occurrence exactly once in deterministic source order.
- [x] 3.5 Build the conservative `Clauses` compatibility flattener without inventing cross-structure compound operators.
- [x] 3.6 Adapt the existing Bash grammar to emit the structural model with no newly supported syntax.
-- [ ] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax.
-- [ ] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results.
+- [x] 3.7 Adapt the existing PowerShell grammar to emit the structural model with no newly supported syntax.
+- [x] 3.8 Add tests proving existing parser inputs retain their v0.2 leaf and compatibility results, except for explicitly promoted v0.3 fail-closed cases.
- [ ] 3.9 Add corpus expectations for syntax shape, occurrences, roles, and completeness for existing constructs.
- [ ] 3.10 Implement Bash `$()` discovery in supported argument words, redirect values, iterables, and expanding heredoc bodies; retain literal/escaped spellings and fail closed on command-name substitutions, legacy backticks, or incomplete interiors.
- [ ] 3.11 Implement PowerShell `$()` discovery in supported words, redirect values, foreach expressions, call-operator dynamic identities, standalone expression statements, double-quoted strings, and expandable here-strings; never invent invocation from standalone output, retain literal/escaped spellings, and fail closed on trailing command-style arguments, call-operator script blocks, or unsupported execution-bearing `@()` / `@{}` forms.
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
index 9e43599..1e6468e 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs
@@ -22,7 +22,7 @@ namespace ShellSyntaxTree.Internal.Pwsh.Parsing;
/// pwsh -Command / -EncodedCommand recursion, and the
/// safe-fail anomaly contract.
///
-internal static class PwshCommandParser
+internal static partial class PwshCommandParser
{
/// Maximum pwsh -Command recursion depth (§10 / §11).
private const int MaxRecursionDepth = 5;
@@ -86,71 +86,13 @@ private static ParsedCommand ParseInternal(
return Unparseable(source, anomalyReason);
}
- var segments = SplitIntoSegments(significant, source, out var splitError);
- if (splitError is not null)
- {
- return Unparseable(source, splitError);
- }
-
- var clauses = new List(segments.Count);
- var attribution = sharedLocation ?? new PwshSetLocationContext();
-
- foreach (var segment in segments)
- {
- if (segment.Tokens.Count == 0)
- {
- continue;
- }
-
- // Effective resolver options reflect Set-Location attribution.
- var effectiveOptions = options;
- var workingDirectoryUnknown = false;
- if (attribution.HasAttribution && !attribution.IsDynamic)
- {
- effectiveOptions = new PwshParserOptions
- {
- HomeDirectory = options.HomeDirectory,
- WorkingDirectory = attribution.ResolvedCwd,
- };
- }
- else if (attribution.IsDynamic)
- {
- workingDirectoryUnknown = true;
- }
-
- var built = BuildSegment(
- segment, source, options, effectiveOptions, workingDirectoryUnknown,
- recursionDepth, markWrapped, attribution);
- if (built.Error is not null)
- {
- return Unparseable(source, built.Error);
- }
-
- for (var k = 0; k < built.Clauses.Count; k++)
- {
- var clause = built.Clauses[k];
-
- // Expanded command-string clauses already carry wrapper and
- // attribution state. Child pwsh uses an isolated context;
- // Invoke-Expression shares and updates this one.
- if (!built.IsRecursion)
- {
- clause = AttachAttributionArg(clause, attribution);
- }
-
- clauses.Add(clause);
- }
-
- // A directly built Set-Location clause updates the attributed cwd
- // for clauses that follow it (§9). Expanded command strings have
- // already updated the appropriate isolated or shared context.
- if (!built.IsRecursion && built.Clauses.Count == 1)
- {
- UpdateAttribution(built.Clauses[0], options, attribution);
- }
- }
-
- return new ParsedCommand { Source = source, Clauses = clauses };
+ return ParseStructured(
+ source,
+ significant,
+ options,
+ recursionDepth,
+ markWrapped,
+ sharedLocation);
}
private static ParsedCommand Unparseable(string source, string? reason) => new()
@@ -244,8 +186,9 @@ private static bool TryDetectUnsupportedInvocationShape(
IReadOnlyList tokens, out string? reason)
{
var verbSlot = true;
- foreach (var token in tokens)
+ for (var index = 0; index < tokens.Count; index++)
{
+ var token = tokens[index];
if (token.Kind == PwshTokenKind.Whitespace)
{
verbSlot = true;
@@ -254,6 +197,14 @@ private static bool TryDetectUnsupportedInvocationShape(
if (token.Kind == PwshTokenKind.Operator)
{
+ if (token.OperatorText == "&" &&
+ index + 1 < tokens.Count &&
+ tokens[index + 1].Kind == PwshTokenKind.ScriptBlock)
+ {
+ reason = "call-operator script blocks are not supported in v0.3";
+ return true;
+ }
+
verbSlot = token.OperatorText is "&&" or "||" or ";" or "|" or "(" or "&";
continue;
}
@@ -469,133 +420,6 @@ private sealed class Segment
public int Depth { get; init; }
}
- private static List SplitIntoSegments(
- IReadOnlyList tokens, string source, out string? error)
- {
- var segments = new List();
- var depth = 0;
- var expressionArgumentDepth = 0;
-
- Segment current = new() { PrecedingOperator = CompoundOperator.None, Depth = 0 };
-
- void Flush()
- {
- if (current.Tokens.Count > 0)
- {
- segments.Add(current);
- }
- }
-
- for (var i = 0; i < tokens.Count; i++)
- {
- var t = tokens[i];
-
- if (expressionArgumentDepth > 0)
- {
- current.Tokens.Add(t);
- if (t.Kind == PwshTokenKind.Operator)
- {
- if (t.OperatorText == "(")
- {
- expressionArgumentDepth++;
- }
- else if (t.OperatorText == ")")
- {
- expressionArgumentDepth--;
- }
- }
-
- continue;
- }
-
- if (t.Kind == PwshTokenKind.Whitespace)
- {
- // A retained whitespace token is a newline statement
- // separator. A separator on an empty pending segment
- // collapses (blank lines, a newline after `|` / `&&` / `||`).
- if (current.Tokens.Count == 0)
- {
- continue;
- }
-
- segments.Add(current);
- current = new Segment { PrecedingOperator = CompoundOperator.Sequence, Depth = depth };
- continue;
- }
-
- if (t.Kind == PwshTokenKind.Operator)
- {
- var op = t.OperatorText;
- if (op == "(")
- {
- if (StartsWithInvokeExpression(current.Tokens))
- {
- current.Tokens.Add(t);
- expressionArgumentDepth = 1;
- continue;
- }
-
- Flush();
- depth++;
- current = new Segment { PrecedingOperator = CompoundOperator.None, Depth = depth };
- continue;
- }
-
- if (op == ")")
- {
- if (depth == 0)
- {
- error = $"unbalanced ')' at position {t.SourceStart}";
- return segments;
- }
-
- depth--;
- Flush();
- current = new Segment { PrecedingOperator = CompoundOperator.None, Depth = depth };
- continue;
- }
-
- if (op is "&&" or "||" or ";" or "|")
- {
- if (current.Tokens.Count == 0 && current.PrecedingOperator != CompoundOperator.None)
- {
- error = $"unexpected operator '{op}' at position {t.SourceStart}";
- return segments;
- }
-
- Flush();
- current = new Segment { PrecedingOperator = MapOperator(op), Depth = depth };
- continue;
- }
-
- // Redirect operators and a bare '&' stay inside the segment.
- current.Tokens.Add(t);
- continue;
- }
-
- current.Tokens.Add(t);
- }
-
- if (depth != 0 || expressionArgumentDepth != 0)
- {
- error = $"unbalanced '(' grouping at position {source.Length}";
- return segments;
- }
-
- Flush();
- error = null;
- return segments;
- }
-
- private static CompoundOperator MapOperator(string? op) => op switch
- {
- "&&" => CompoundOperator.AndIf,
- "||" => CompoundOperator.OrIf,
- ";" => CompoundOperator.Sequence,
- "|" => CompoundOperator.Pipe,
- _ => CompoundOperator.None,
- };
-
private static bool StartsWithInvokeExpression(List tokens)
{
var verbIndex = tokens.Count > 0
@@ -619,24 +443,38 @@ private readonly struct BuildResult
{
public IReadOnlyList Clauses { get; }
+ public ShellSyntaxNode? Syntax { get; }
+
public string? Error { get; }
public bool IsRecursion { get; }
- private BuildResult(IReadOnlyList clauses, string? error, bool isRecursion)
+ private BuildResult(
+ IReadOnlyList clauses,
+ ShellSyntaxNode? syntax,
+ string? error,
+ bool isRecursion)
{
Clauses = clauses;
+ Syntax = syntax;
Error = error;
IsRecursion = isRecursion;
}
- public static BuildResult Ok(Clause c) => new(new[] { c }, null, false);
+ public static BuildResult Ok(Clause c) => new(new[] { c }, null, null, false);
public static BuildResult Recursion(IReadOnlyList clauses) =>
- new(clauses, null, true);
+ new(clauses, null, null, true);
+
+ public static BuildResult Recursion(ShellSyntaxNode syntax) =>
+ new(Array.Empty(), syntax, null, true);
public static BuildResult Fail(string? reason) =>
- new(Array.Empty(), reason ?? "inner parse failed", false);
+ new(
+ Array.Empty(),
+ null,
+ reason ?? "inner parse failed",
+ false);
}
private static BuildResult BuildSegment(
@@ -2002,21 +1840,20 @@ private static bool TryHandleInvokeExpression(
return true;
}
- var expanded = new List(innerParsed.Clauses.Count);
- for (var i = 0; i < innerParsed.Clauses.Count; i++)
+ if (!TryBuildDecodedWrapper(
+ innerParsed,
+ segment,
+ ShellGroupKind.CurrentScope,
+ Array.Empty(),
+ Array.Empty(),
+ out var wrapper))
{
- var innerClause = innerParsed.Clauses[i];
- expanded.Add(innerClause with
- {
- Operator = i == 0 ? segment.PrecedingOperator : innerClause.Operator,
- IsSubshell = segment.Depth > 0 || innerClause.IsSubshell,
- IsCommandStringWrapped = true,
- Elements = ClauseElementProvenance.WithoutOuterSourceSpans(
- innerClause.Elements),
- });
+ result = BuildResult.Fail(
+ "Invoke-Expression decoded syntax could not be lifted safely");
+ return true;
}
- result = BuildResult.Recursion(expanded);
+ result = BuildResult.Recursion(wrapper!);
return true;
}
@@ -2165,6 +2002,15 @@ private static bool TryRecurseIntoPwsh(
continue;
}
+ if (!HasExactWrapperPrefix(body, start + 1, i))
+ {
+ // A computed or noncanonical token before the command-string
+ // switch can itself change host option binding. Preserve the
+ // outer clause as incomplete instead of decoding one possible
+ // interpretation.
+ return false;
+ }
+
if (failure is not null)
{
result = BuildResult.Fail(failure);
@@ -2179,6 +2025,14 @@ private static bool TryRecurseIntoPwsh(
return true;
}
+ if (!isEncoded && string.Equals(inner, "-", StringComparison.Ordinal))
+ {
+ // `-Command -` reads executable input from stdin. Retain the
+ // outer host clause so completeness can fail closed without
+ // inventing a literal command named `-`.
+ return false;
+ }
+
if (!TryBuildWrapperRedirects(
body,
redirectStart,
@@ -2209,64 +2063,58 @@ private static bool TryRecurseIntoPwsh(
return true;
}
- var expanded = new List(innerParsed.Clauses.Count);
- for (var k = 0; k < innerParsed.Clauses.Count; k++)
+ if (!TryBuildDecodedWrapper(
+ innerParsed,
+ segment,
+ ShellGroupKind.IsolatedScope,
+ wrapperRedirects,
+ wrapperRedirectElements,
+ out var wrapper))
{
- var ic = innerParsed.Clauses[k];
- expanded.Add(ic with
- {
- Operator = k == 0 ? segment.PrecedingOperator : ic.Operator,
- IsSubshell = segment.Depth > 0 || ic.IsSubshell,
- IsCommandStringWrapped = true,
- Elements = ClauseElementProvenance.WithoutOuterSourceSpans(ic.Elements),
- });
+ result = BuildResult.Fail(
+ "pwsh decoded syntax could not be lifted safely");
+ return true;
}
- if (expanded.Count == 0 && wrapperRedirects.Count > 0)
+ result = BuildResult.Recursion(wrapper!);
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool HasExactWrapperPrefix(
+ IReadOnlyList body,
+ int start,
+ int end)
+ {
+ for (var index = start; index < end; index++)
+ {
+ var token = body[index];
+ if (token.Kind != PwshTokenKind.Parameter ||
+ token.HasInterpolation ||
+ token.ResolverValue is null ||
+ !string.Equals(
+ token.ResolverValue.Decoded,
+ token.Value,
+ StringComparison.Ordinal))
{
- expanded.Add(new Clause
- {
- Operator = segment.PrecedingOperator,
- Verb = new VerbChain(),
- Args = Array.Empty(),
- Redirects = wrapperRedirects,
- Elements = wrapperRedirectElements,
- IsSubshell = segment.Depth > 0,
- IsCommandStringWrapped = true,
- });
+ return false;
}
- else if (expanded.Count > 0 && wrapperRedirects.Count > 0)
- {
- var lastIndex = expanded.Count - 1;
- var lastClause = expanded[lastIndex];
- var redirects = new List(lastClause.Redirects.Count + wrapperRedirects.Count);
- redirects.AddRange(lastClause.Redirects);
- redirects.AddRange(wrapperRedirects);
-
- var elements = new List(
- lastClause.Elements.Count + wrapperRedirectElements.Count);
- elements.AddRange(lastClause.Elements);
- var precedingVerbCount = lastClause.Verb.Tokens.Count;
- foreach (var redirectElement in wrapperRedirectElements)
- {
- elements.Add(redirectElement with
- {
- PrecedingVerbElementCount = precedingVerbCount,
- });
- }
- expanded[lastIndex] = lastClause with
+ foreach (var fragment in token.ResolverValue.Fragments)
+ {
+ if (fragment.Kind != ShellValueFragmentKind.Literal ||
+ fragment.Expansion is not null ||
+ fragment.Cardinality != ShellValueCardinality.ExactlyOne ||
+ fragment.OpaqueCause != ShellOpaqueCause.None)
{
- Redirects = redirects,
- Elements = elements,
- };
+ return false;
+ }
}
-
- result = BuildResult.Recursion(expanded);
- return true;
}
- return false;
+ return true;
}
private static bool IsCommandParameter(string name)
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs
new file mode 100644
index 0000000..1b46a75
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs
@@ -0,0 +1,979 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using ShellSyntaxTree.Internal.Parsing;
+using ShellSyntaxTree.Internal.Pwsh.Lexing;
+
+namespace ShellSyntaxTree.Internal.Pwsh.Parsing;
+
+internal static partial class PwshCommandParser
+{
+ private static ParsedCommand ParseStructured(
+ string source,
+ IReadOnlyList tokens,
+ PwshParserOptions options,
+ int recursionDepth,
+ bool markWrapped,
+ PwshSetLocationContext? sharedLocation)
+ {
+ var coordinator = new StructuralCoordinator(
+ source,
+ tokens,
+ options,
+ recursionDepth,
+ markWrapped,
+ sharedLocation);
+ if (!coordinator.TryParse(out var syntax, out var error))
+ {
+ return StructuralFailure(source, error, syntax);
+ }
+
+ if (!ShellSyntaxProjection.TryProject(
+ syntax,
+ simple => new CommandOccurrenceFacts
+ {
+ IsComplete = IsStructurallyComplete(simple.Clause),
+ },
+ out var projection))
+ {
+ return StructuralFailure(
+ source,
+ "PowerShell structural syntax exceeded limits or contained invalid parser-owned facts",
+ syntax);
+ }
+
+ return new ParsedCommand
+ {
+ Source = source,
+ Syntax = syntax,
+ Commands = projection.Commands,
+ Clauses = projection.Clauses,
+ };
+ }
+
+ private static ParsedCommand StructuralFailure(
+ string source,
+ string? reason,
+ ShellBlockSyntax? syntax = null) => new()
+ {
+ Source = source,
+ Syntax = syntax ?? new ShellBlockSyntax(),
+ Commands = Array.Empty(),
+ Clauses = Array.Empty(),
+ IsUnparseable = true,
+ UnparseableReason = reason,
+ };
+
+ private sealed class StructuralCoordinator
+ {
+ private readonly string _source;
+ private readonly IReadOnlyList _tokens;
+ private readonly PwshParserOptions _options;
+ private readonly int _recursionDepth;
+ private readonly bool _markWrapped;
+ private readonly PwshSetLocationContext _attribution;
+ private int _position;
+ private int _groupDepth;
+
+ internal StructuralCoordinator(
+ string source,
+ IReadOnlyList tokens,
+ PwshParserOptions options,
+ int recursionDepth,
+ bool markWrapped,
+ PwshSetLocationContext? sharedLocation)
+ {
+ _source = source;
+ _tokens = tokens;
+ _options = options;
+ _recursionDepth = recursionDepth;
+ _markWrapped = markWrapped;
+ _attribution = sharedLocation ?? new PwshSetLocationContext();
+ }
+
+ internal bool TryParse(out ShellBlockSyntax syntax, out string? error)
+ {
+ SkipNewlines();
+ if (_position == _tokens.Count)
+ {
+ syntax = new ShellBlockSyntax
+ {
+ SourceStart = 0,
+ SourceLength = _source.Length,
+ };
+ error = null;
+ return true;
+ }
+
+ if (!TryParseList(CompoundOperator.None, out var command, out error) ||
+ command is null)
+ {
+ syntax = new ShellBlockSyntax();
+ return false;
+ }
+
+ SkipNewlines();
+ if (_position != _tokens.Count)
+ {
+ syntax = new ShellBlockSyntax();
+ error = IsOperator(")")
+ ? $"unbalanced ')' at position {_tokens[_position].SourceStart}"
+ : $"unexpected token at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ syntax = new ShellBlockSyntax
+ {
+ Statements = new[] { command },
+ SourceStart = 0,
+ SourceLength = _source.Length,
+ };
+ return true;
+ }
+
+ private bool TryParseList(
+ CompoundOperator firstCompatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ error = null;
+ SkipNewlines();
+ if (_position == _tokens.Count)
+ {
+ return true;
+ }
+
+ if (!TryParsePipeline(firstCompatibilityOperator, out var first, out error))
+ {
+ return false;
+ }
+
+ var items = new List
+ {
+ new() { Operator = CompoundOperator.None, Command = first! },
+ };
+
+ while (_position < _tokens.Count)
+ {
+ if (IsOperator(")"))
+ {
+ error = $"unbalanced ')' at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ if (!TryReadListOperator(out var listOperator))
+ {
+ error = $"unexpected token at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ SkipNewlines();
+ if (_position == _tokens.Count)
+ {
+ if (listOperator == CompoundOperator.Sequence)
+ {
+ break;
+ }
+
+ error = "compound operator is missing a following command";
+ return false;
+ }
+
+ if (!TryParsePipeline(listOperator, out var next, out error))
+ {
+ return false;
+ }
+
+ items.Add(new CommandListItemSyntax
+ {
+ Operator = listOperator,
+ Command = next!,
+ });
+ }
+
+ if (items.Count == 1)
+ {
+ command = first;
+ return true;
+ }
+
+ var children = new List(items.Count);
+ foreach (var item in items)
+ {
+ children.Add(item.Command);
+ }
+
+ command = new CommandListSyntax
+ {
+ Items = items,
+ SourceStart = CombinedStart(children),
+ SourceLength = CombinedLength(children),
+ };
+ return true;
+ }
+
+ private bool TryParsePipeline(
+ CompoundOperator firstCompatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ if (!TryParseCommand(firstCompatibilityOperator, out var first, out error))
+ {
+ return false;
+ }
+
+ var stages = new List { first! };
+ while (IsOperator("|"))
+ {
+ _position++;
+ SkipNewlines();
+ if (_position == _tokens.Count || IsOperator(")"))
+ {
+ error = "pipeline operator is missing a following command";
+ return false;
+ }
+
+ if (IsOperator("("))
+ {
+ error = "a grouped expression is only supported as the first pipeline element";
+ return false;
+ }
+
+ if (!TryParseCommand(CompoundOperator.Pipe, out var stage, out error))
+ {
+ return false;
+ }
+
+ stages.Add(stage!);
+ }
+
+ if (stages.Count == 1)
+ {
+ command = first;
+ return true;
+ }
+
+ command = new PipelineSyntax
+ {
+ Stages = stages,
+ SourceStart = CombinedStart(stages),
+ SourceLength = CombinedLength(stages),
+ };
+ error = null;
+ return true;
+ }
+
+ private bool TryParseCommand(
+ CompoundOperator compatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ command = null;
+ error = null;
+ if (_position == _tokens.Count)
+ {
+ error = "expected a command";
+ return false;
+ }
+
+ if (IsOperator("("))
+ {
+ return TryParseGroup(compatibilityOperator, out command, out error);
+ }
+
+ if (IsOperator(")") || IsListOperator(_tokens[_position]) || IsOperator("|"))
+ {
+ error = $"unexpected operator at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ var start = _position;
+ var expressionDepth = 0;
+ while (_position < _tokens.Count)
+ {
+ var token = _tokens[_position];
+ if (expressionDepth > 0)
+ {
+ _position++;
+ if (token.Kind == PwshTokenKind.Operator)
+ {
+ if (token.OperatorText == "(")
+ {
+ expressionDepth++;
+ }
+ else if (token.OperatorText == ")")
+ {
+ expressionDepth--;
+ }
+ }
+
+ continue;
+ }
+
+ if (token.Kind == PwshTokenKind.Operator && token.OperatorText == "(" &&
+ StartsWithInvokeExpression(CopyTokens(start, _position)))
+ {
+ expressionDepth = 1;
+ _position++;
+ continue;
+ }
+
+ if (IsStructuralBoundary(token))
+ {
+ break;
+ }
+
+ _position++;
+ }
+
+ if (expressionDepth != 0)
+ {
+ error = $"unbalanced '(' grouping at position {_source.Length}";
+ return false;
+ }
+
+ var segmentTokens = CopyTokens(start, _position);
+ if (segmentTokens.Count == 0)
+ {
+ error = $"expected a command at position {_tokens[_position].SourceStart}";
+ return false;
+ }
+
+ var effectiveOptions = _options;
+ var workingDirectoryUnknown = false;
+ if (_attribution.HasAttribution && !_attribution.IsDynamic)
+ {
+ effectiveOptions = new PwshParserOptions
+ {
+ HomeDirectory = _options.HomeDirectory,
+ WorkingDirectory = _attribution.ResolvedCwd,
+ };
+ }
+ else if (_attribution.IsDynamic)
+ {
+ workingDirectoryUnknown = true;
+ }
+
+ var segment = new Segment
+ {
+ PrecedingOperator = compatibilityOperator,
+ Depth = _groupDepth,
+ };
+ segment.Tokens.AddRange(segmentTokens);
+ var built = BuildSegment(
+ segment,
+ _source,
+ _options,
+ effectiveOptions,
+ workingDirectoryUnknown,
+ _recursionDepth,
+ _markWrapped,
+ _attribution);
+ if (built.Error is not null)
+ {
+ error = built.Error;
+ return false;
+ }
+
+ if (built.Syntax is not null)
+ {
+ command = built.Syntax;
+ return true;
+ }
+
+ if (built.Clauses.Count != 1)
+ {
+ error = "simple-command parser did not produce exactly one compatibility leaf";
+ return false;
+ }
+
+ var clause = built.Clauses[0];
+ if (!built.IsRecursion)
+ {
+ clause = AttachAttributionArg(clause, _attribution);
+ UpdateAttribution(built.Clauses[0], _options, _attribution);
+ }
+
+ var first = segmentTokens[0];
+ var last = segmentTokens[segmentTokens.Count - 1];
+ command = new SimpleCommandSyntax
+ {
+ Clause = clause,
+ SourceStart = first.SourceStart,
+ SourceLength = last.SourceStart + last.SourceLength - first.SourceStart,
+ };
+ return true;
+ }
+
+ private bool TryParseGroup(
+ CompoundOperator compatibilityOperator,
+ out ShellSyntaxNode? command,
+ out string? error)
+ {
+ if (_groupDepth >= ShellAnalysisLimits.MaxStructuralNesting)
+ {
+ command = null;
+ error = "PowerShell structural nesting depth exceeded (>16)";
+ return false;
+ }
+
+ var open = _tokens[_position++];
+ _groupDepth++;
+ SkipNewlines();
+ var parsed = TryParsePipeline(
+ compatibilityOperator,
+ out var bodyCommand,
+ out error);
+ _groupDepth--;
+ if (!parsed)
+ {
+ command = null;
+ return false;
+ }
+
+ SkipNewlines();
+ if (_position == _tokens.Count || !IsOperator(")"))
+ {
+ command = null;
+ error = $"unbalanced '(' grouping at position {_source.Length}";
+ return false;
+ }
+
+ var close = _tokens[_position++];
+ command = new GroupSyntax
+ {
+ GroupKind = ShellGroupKind.CurrentScope,
+ Body = new ShellBlockSyntax
+ {
+ Statements = bodyCommand is null
+ ? Array.Empty()
+ : new[] { bodyCommand },
+ SourceStart = open.SourceStart + open.SourceLength,
+ SourceLength = close.SourceStart - open.SourceStart - open.SourceLength,
+ },
+ SourceStart = open.SourceStart,
+ SourceLength = close.SourceStart + close.SourceLength - open.SourceStart,
+ };
+ return true;
+ }
+
+ private List CopyTokens(int start, int end)
+ {
+ var copied = new List(end - start);
+ for (var index = start; index < end; index++)
+ {
+ copied.Add(_tokens[index]);
+ }
+
+ return copied;
+ }
+
+ private bool TryReadListOperator(out CompoundOperator @operator)
+ {
+ @operator = CompoundOperator.None;
+ if (_position == _tokens.Count)
+ {
+ return false;
+ }
+
+ var token = _tokens[_position];
+ if (token.Kind == PwshTokenKind.Whitespace)
+ {
+ @operator = CompoundOperator.Sequence;
+ _position++;
+ return true;
+ }
+
+ if (token.Kind != PwshTokenKind.Operator)
+ {
+ return false;
+ }
+
+ @operator = token.OperatorText switch
+ {
+ "&&" => CompoundOperator.AndIf,
+ "||" => CompoundOperator.OrIf,
+ ";" => CompoundOperator.Sequence,
+ _ => CompoundOperator.None,
+ };
+ if (@operator == CompoundOperator.None)
+ {
+ return false;
+ }
+
+ _position++;
+ return true;
+ }
+
+ private void SkipNewlines()
+ {
+ while (_position < _tokens.Count &&
+ _tokens[_position].Kind == PwshTokenKind.Whitespace)
+ {
+ _position++;
+ }
+ }
+
+ private bool IsOperator(string value) =>
+ _position < _tokens.Count &&
+ _tokens[_position].Kind == PwshTokenKind.Operator &&
+ string.Equals(_tokens[_position].OperatorText, value, StringComparison.Ordinal);
+ }
+
+ private static bool IsStructuralBoundary(PwshToken token) =>
+ token.Kind == PwshTokenKind.Whitespace ||
+ token.Kind == PwshTokenKind.Operator && token.OperatorText is
+ "(" or ")" or "&&" or "||" or ";" or "|";
+
+ private static bool IsListOperator(PwshToken token) =>
+ token.Kind == PwshTokenKind.Whitespace ||
+ token.Kind == PwshTokenKind.Operator && token.OperatorText is "&&" or "||" or ";";
+
+ private sealed class DecodedCloneState
+ {
+ internal CompoundOperator FirstOperator { get; init; }
+
+ internal bool OuterSubshell { get; init; }
+
+ internal int LeafCount { get; init; }
+
+ internal IReadOnlyList WrapperRedirects { get; init; } =
+ Array.Empty();
+
+ internal IReadOnlyList WrapperRedirectElements { get; init; } =
+ Array.Empty();
+
+ internal int LeafIndex { get; set; }
+ }
+
+ private static bool TryBuildDecodedWrapper(
+ ParsedCommand inner,
+ Segment outer,
+ ShellGroupKind groupKind,
+ IReadOnlyList wrapperRedirects,
+ IReadOnlyList wrapperRedirectElements,
+ out GroupSyntax? wrapper)
+ {
+ var state = new DecodedCloneState
+ {
+ FirstOperator = outer.PrecedingOperator,
+ OuterSubshell = outer.Depth > 0,
+ LeafCount = inner.Commands.Count,
+ WrapperRedirects = wrapperRedirects,
+ WrapperRedirectElements = wrapperRedirectElements,
+ };
+ if (!TryCloneDecodedBlock(inner.Syntax, state, out var body))
+ {
+ wrapper = null;
+ return false;
+ }
+
+ if (state.LeafCount == 0 && wrapperRedirects.Count > 0)
+ {
+ var redirectElements = new List(wrapperRedirectElements.Count);
+ foreach (var element in wrapperRedirectElements)
+ {
+ redirectElements.Add(element with { PrecedingVerbElementCount = 0 });
+ }
+
+ body = new ShellBlockSyntax
+ {
+ Statements = new ShellSyntaxNode[]
+ {
+ new SimpleCommandSyntax
+ {
+ Clause = new Clause
+ {
+ Operator = outer.PrecedingOperator,
+ Verb = new VerbChain(),
+ Args = Array.Empty(),
+ Redirects = wrapperRedirects,
+ Elements = redirectElements,
+ IsSubshell = outer.Depth > 0,
+ IsCommandStringWrapped = true,
+ },
+ },
+ },
+ };
+ }
+
+ if (outer.Tokens.Count == 0)
+ {
+ wrapper = null;
+ return false;
+ }
+
+ var first = outer.Tokens[0];
+ var last = outer.Tokens[outer.Tokens.Count - 1];
+ wrapper = new GroupSyntax
+ {
+ GroupKind = groupKind,
+ Body = body,
+ SourceStart = first.SourceStart,
+ SourceLength = last.SourceStart + last.SourceLength - first.SourceStart,
+ };
+ return true;
+ }
+
+ private static bool TryCloneDecodedBlock(
+ ShellBlockSyntax source,
+ DecodedCloneState state,
+ out ShellBlockSyntax clone)
+ {
+ if (source is null || source.Statements is null)
+ {
+ clone = new ShellBlockSyntax();
+ return false;
+ }
+
+ var statements = new List(source.Statements.Count);
+ foreach (var statement in source.Statements)
+ {
+ if (!TryCloneDecodedNode(statement, state, out var cloned))
+ {
+ clone = new ShellBlockSyntax();
+ return false;
+ }
+
+ statements.Add(cloned!);
+ }
+
+ clone = new ShellBlockSyntax { Statements = statements };
+ return true;
+ }
+
+ private static bool TryCloneDecodedNode(
+ ShellSyntaxNode source,
+ DecodedCloneState state,
+ out ShellSyntaxNode? clone)
+ {
+ clone = null;
+ switch (source)
+ {
+ case ShellBlockSyntax block:
+ if (!TryCloneDecodedBlock(block, state, out var clonedBlock))
+ {
+ return false;
+ }
+
+ clone = clonedBlock;
+ return true;
+ case SimpleCommandSyntax simple:
+ var substitutions = new List(simple.Substitutions.Count);
+ foreach (var substitution in simple.Substitutions)
+ {
+ if (!TryCloneDecodedNode(substitution, state, out var clonedSubstitution) ||
+ clonedSubstitution is not CommandSubstitutionSyntax typedSubstitution)
+ {
+ return false;
+ }
+
+ substitutions.Add(typedSubstitution);
+ }
+
+ var isFirst = state.LeafIndex == 0;
+ var isLast = state.LeafIndex == state.LeafCount - 1;
+ state.LeafIndex++;
+ var redirects = new List(simple.Clause.Redirects.Count +
+ (isLast ? state.WrapperRedirects.Count : 0));
+ redirects.AddRange(simple.Clause.Redirects);
+ if (isLast)
+ {
+ redirects.AddRange(state.WrapperRedirects);
+ }
+
+ var elements = new List(simple.Clause.Elements.Count +
+ (isLast ? state.WrapperRedirectElements.Count : 0));
+ elements.AddRange(ClauseElementProvenance.WithoutOuterSourceSpans(
+ simple.Clause.Elements));
+ if (isLast)
+ {
+ foreach (var redirectElement in state.WrapperRedirectElements)
+ {
+ elements.Add(redirectElement with
+ {
+ PrecedingVerbElementCount = simple.Clause.Verb.Tokens.Count,
+ });
+ }
+ }
+
+ clone = new SimpleCommandSyntax
+ {
+ Clause = simple.Clause with
+ {
+ Operator = isFirst ? state.FirstOperator : simple.Clause.Operator,
+ Redirects = redirects,
+ Elements = elements,
+ IsSubshell = state.OuterSubshell || simple.Clause.IsSubshell,
+ IsCommandStringWrapped = true,
+ },
+ Substitutions = substitutions,
+ };
+ return true;
+ case PipelineSyntax pipeline:
+ return TryCloneDecodedCollection(
+ pipeline.Stages,
+ state,
+ stages => new PipelineSyntax { Stages = stages },
+ out clone);
+ case CommandListSyntax list:
+ var items = new List(list.Items.Count);
+ foreach (var item in list.Items)
+ {
+ if (item is null ||
+ !TryCloneDecodedNode(item.Command, state, out var itemCommand))
+ {
+ return false;
+ }
+
+ items.Add(new CommandListItemSyntax
+ {
+ Operator = item.Operator,
+ Command = itemCommand!,
+ });
+ }
+
+ clone = new CommandListSyntax { Items = items };
+ return true;
+ case GroupSyntax group:
+ if (!TryCloneDecodedBlock(group.Body, state, out var groupBody))
+ {
+ return false;
+ }
+
+ clone = new GroupSyntax { GroupKind = group.GroupKind, Body = groupBody };
+ return true;
+ case ForEachSyntax forEach:
+ if (!TryCloneDecodedBlock(forEach.IteratorCommands, state, out var iterator) ||
+ !TryCloneDecodedBlock(forEach.Body, state, out var forBody))
+ {
+ return false;
+ }
+
+ clone = new ForEachSyntax
+ {
+ Binding = new LoopBindingSyntax
+ {
+ Name = forEach.Binding.Name,
+ Source = ClearSpan(forEach.Binding.Source),
+ },
+ Iterable = ClearSpan(forEach.Iterable),
+ IteratorCommands = iterator,
+ Body = forBody,
+ };
+ return true;
+ case ConditionLoopSyntax loop:
+ if (!TryCloneDecodedBlock(loop.Condition, state, out var condition) ||
+ !TryCloneDecodedBlock(loop.Body, state, out var loopBody))
+ {
+ return false;
+ }
+
+ clone = new ConditionLoopSyntax
+ {
+ LoopKind = loop.LoopKind,
+ Condition = condition,
+ Body = loopBody,
+ };
+ return true;
+ case ConditionalSyntax conditional:
+ var branches = new List(conditional.Branches.Count);
+ foreach (var branch in conditional.Branches)
+ {
+ if (!TryCloneDecodedNode(branch, state, out var clonedBranch) ||
+ clonedBranch is not ConditionalBranchSyntax typedBranch)
+ {
+ return false;
+ }
+
+ branches.Add(typedBranch);
+ }
+
+ ShellBlockSyntax? @else = null;
+ if (conditional.Else is not null &&
+ !TryCloneDecodedBlock(conditional.Else, state, out @else))
+ {
+ return false;
+ }
+
+ clone = new ConditionalSyntax { Branches = branches, Else = @else };
+ return true;
+ case ConditionalBranchSyntax branch:
+ if (!TryCloneDecodedBlock(branch.Condition, state, out var branchCondition) ||
+ !TryCloneDecodedBlock(branch.Body, state, out var branchBody))
+ {
+ return false;
+ }
+
+ clone = new ConditionalBranchSyntax
+ {
+ Condition = branchCondition,
+ Body = branchBody,
+ };
+ return true;
+ case CommandSubstitutionSyntax substitution:
+ if (!TryCloneDecodedBlock(substitution.Body, state, out var substitutionBody))
+ {
+ return false;
+ }
+
+ clone = new CommandSubstitutionSyntax { Body = substitutionBody };
+ return true;
+ default:
+ return false;
+ }
+ }
+
+ private static bool TryCloneDecodedCollection(
+ IReadOnlyList source,
+ DecodedCloneState state,
+ Func, ShellSyntaxNode> factory,
+ out ShellSyntaxNode? clone)
+ {
+ var children = new List(source.Count);
+ foreach (var child in source)
+ {
+ if (!TryCloneDecodedNode(child, state, out var clonedChild))
+ {
+ clone = null;
+ return false;
+ }
+
+ children.Add(clonedChild!);
+ }
+
+ clone = factory(children);
+ return true;
+ }
+
+ private static ShellSourceFragment ClearSpan(ShellSourceFragment source) => new()
+ {
+ Raw = source.Raw,
+ };
+
+ private static bool IsStructurallyComplete(Clause clause)
+ {
+ if (clause.Redirects.Count > 0 ||
+ clause.Verb.IsDynamic ||
+ clause.Verb.Tokens.Count == 0)
+ {
+ return false;
+ }
+
+ if (HasUnexpandedPowerShellCommandString(clause))
+ {
+ return false;
+ }
+
+ foreach (var element in clause.Elements)
+ {
+ if (element.Kind == ArgKind.DynamicSkip &&
+ (element.Raw.IndexOf("$(", StringComparison.Ordinal) >= 0 ||
+ element.Raw.IndexOf("@(", StringComparison.Ordinal) >= 0 ||
+ element.Raw.IndexOf("@{", StringComparison.Ordinal) >= 0))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ private static bool HasUnexpandedPowerShellCommandString(Clause clause)
+ {
+ var verb = clause.Verb.CanonicalVerb ??
+ (clause.Verb.Tokens.Count > 0 ? clause.Verb.Tokens[0] : null);
+ if (verb is null)
+ {
+ return false;
+ }
+
+ if (IsInvokeExpressionName(verb))
+ {
+ return true;
+ }
+
+ if (!IsPowerShellHostName(verb))
+ {
+ return false;
+ }
+
+ foreach (var element in clause.Elements)
+ {
+ if (element.Role != ClauseElementRole.Argument)
+ {
+ continue;
+ }
+
+ if (element.Kind != ArgKind.Literal ||
+ LooksLikePowerShellCommandStringOption(element.Value))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private static bool IsPowerShellHostName(string verb) =>
+ string.Equals(verb, "pwsh", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(verb, "pwsh.exe", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(verb, "powershell", StringComparison.OrdinalIgnoreCase) ||
+ string.Equals(verb, "powershell.exe", StringComparison.OrdinalIgnoreCase);
+
+ private static bool LooksLikePowerShellCommandStringOption(string value)
+ {
+ if (value.Length < 2 || value[0] != '-')
+ {
+ return false;
+ }
+
+ var name = value;
+ var colon = name.IndexOf(':');
+ if (colon > 0)
+ {
+ name = name.Substring(0, colon);
+ }
+
+ return IsCommandParameter(name) ||
+ IsEncodedCommandParameter(name) ||
+ string.Equals(name, "-cwa", StringComparison.OrdinalIgnoreCase) ||
+ name.StartsWith("-commandw", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static int? CombinedStart(IReadOnlyList nodes)
+ {
+ if (nodes.Count == 0 || nodes[0].SourceStart is null)
+ {
+ return null;
+ }
+
+ for (var index = 1; index < nodes.Count; index++)
+ {
+ if (nodes[index].SourceStart is null)
+ {
+ return null;
+ }
+ }
+
+ return nodes[0].SourceStart;
+ }
+
+ private static int? CombinedLength(IReadOnlyList nodes)
+ {
+ var start = CombinedStart(nodes);
+ var last = nodes.Count == 0 ? null : nodes[nodes.Count - 1];
+ if (start is null || last?.SourceStart is null || last.SourceLength is null)
+ {
+ return null;
+ }
+
+ return last.SourceStart.Value + last.SourceLength.Value - start.Value;
+ }
+}
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 067d15c..1fdfee2 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json
+++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/162_dynamic_call_scriptblock.json
@@ -2,18 +2,9 @@
"name": "Dynamic call scriptblock",
"input": "\u0026 { Get-Date }",
"expected": {
- "isUnparseable": false,
- "clauses": [
- {
- "operator": "None",
- "verb": [
- "{ Get-Date }"
- ],
- "isDynamic": true,
- "args": [],
- "redirects": []
- }
- ]
+ "isUnparseable": true,
+ "unparseableReasonContains": "call-operator script blocks are not supported in v0.3"
},
- "notes": "The call operator on a script block \u2014 dynamic command."
+ "notes": "The call operator executes a script block. Stable v0.3 fails closed until its body and scope are modeled.",
+ "oracleExpectation": "OutOfScope"
}
diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json
index fc731ba..cef95a5 100644
--- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json
+++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json
@@ -134,7 +134,8 @@
"commands": [],
"compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false },
"securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"]
- }
+ },
+ "compatibilityProjectionLanded": true
},
{
"id": "pwsh-standalone-subexpression-is-not-invocation",
diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs
new file mode 100644
index 0000000..c852226
--- /dev/null
+++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs
@@ -0,0 +1,367 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System.Collections.Generic;
+using System.Linq;
+using Xunit;
+
+namespace ShellSyntaxTree.Tests.Parsing;
+
+/// Pins the v0.3 PowerShell syntax and authorization projections.
+public class PwshStructuralProjectionTests
+{
+ [Fact]
+ public void Simple_command_populates_all_projections_with_shared_leaf_identity()
+ {
+ const string source = "Get-Item child.txt";
+
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ Assert.Equal(0, result.Syntax.SourceStart);
+ Assert.Equal(source.Length, result.Syntax.SourceLength);
+ var simple = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(0, simple.SourceStart);
+ Assert.Equal(source.Length, simple.SourceLength);
+
+ var occurrence = Assert.Single(result.Commands);
+ var clause = Assert.Single(result.Clauses);
+ Assert.Same(clause, simple.Clause);
+ Assert.Same(clause, occurrence.Clause);
+ Assert.True(occurrence.IsComplete);
+ Assert.Equal(CommandOccurrenceRole.Ordinary, occurrence.ImmediateRole);
+ }
+
+ [Fact]
+ public void Mixed_pipeline_and_list_preserve_authored_structure_and_roles()
+ {
+ var result = Parse("Get-Item x | Select-Object Name && Get-Date");
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(2, list.Items.Count);
+ Assert.Equal(CompoundOperator.AndIf, list.Items[1].Operator);
+ var pipeline = Assert.IsType(list.Items[0].Command);
+ Assert.Equal(2, pipeline.Stages.Count);
+ Assert.IsType(list.Items[1].Command);
+
+ Assert.Equal(3, result.Commands.Count);
+ Assert.Equal(
+ new[]
+ {
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.PipelineStage,
+ CommandOccurrenceRole.Ordinary,
+ },
+ result.Commands.Select(command => command.ImmediateRole));
+ Assert.Equal(result.Clauses, result.Commands.Select(command => command.Clause));
+ }
+
+ [Fact]
+ public void Nested_groups_are_current_scope_and_keep_exact_source_ranges()
+ {
+ const string source = "((Get-Item x | Select-Object Name))";
+
+ var result = Parse(source);
+
+ var outer = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var inner = Assert.IsType(Assert.Single(outer.Body.Statements));
+ Assert.Equal(ShellGroupKind.CurrentScope, outer.GroupKind);
+ Assert.Equal(ShellGroupKind.CurrentScope, inner.GroupKind);
+ Assert.Equal(0, outer.SourceStart);
+ Assert.Equal(source.Length, outer.SourceLength);
+ Assert.Equal(1, inner.SourceStart);
+ Assert.Equal(source.Length - 2, inner.SourceLength);
+ Assert.IsType(Assert.Single(inner.Body.Statements));
+ Assert.All(result.Clauses, clause => Assert.True(clause.IsSubshell));
+ }
+
+ [Fact]
+ public void Group_collapses_leading_and_trailing_newlines_without_admitting_a_list()
+ {
+ var accepted = Parse("(\nGet-Date\n)");
+ var rejected = Parse("(\nGet-Date\nGet-Process\n)");
+
+ Assert.False(accepted.IsUnparseable);
+ Assert.IsType(Assert.Single(accepted.Syntax.Statements));
+ Assert.True(rejected.IsUnparseable);
+ Assert.Empty(rejected.Commands);
+ Assert.Empty(rejected.Clauses);
+ }
+
+ [Fact]
+ public void Set_location_propagates_out_of_a_current_scope_group()
+ {
+ var result = Parse("(Set-Location C:\\sensitive); Remove-Item child.txt");
+
+ var remove = result.Clauses.Last();
+ Assert.Contains(
+ remove.Args,
+ arg => arg.IsCwdAttribution && arg.Resolved == "C:/sensitive");
+ }
+
+ [Theory]
+ [InlineData("(Get-Date; Get-Process)")]
+ [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);
+
+ Assert.True(result.IsUnparseable);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ }
+
+ [Fact]
+ public void Operator_entering_group_is_carried_by_its_first_compatibility_leaf()
+ {
+ var result = Parse("Get-Date || (Get-Item x | Select-Object Name)");
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var group = Assert.IsType(list.Items[1].Command);
+ Assert.Equal(ShellGroupKind.CurrentScope, group.GroupKind);
+ Assert.Equal(
+ new[]
+ {
+ CompoundOperator.None,
+ CompoundOperator.OrIf,
+ CompoundOperator.Pipe,
+ },
+ result.Clauses.Select(clause => clause.Operator));
+ }
+
+ [Fact]
+ public void Exact_structural_depth_limit_remains_parseable()
+ {
+ var source = new string('(', ShellAnalysisLimits.MaxStructuralNesting)
+ + "Get-Date"
+ + new string(')', ShellAnalysisLimits.MaxStructuralNesting);
+
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ Assert.Single(result.Commands);
+ }
+
+ [Fact]
+ public void Structural_depth_overflow_fails_before_recursive_descent()
+ {
+ const int hostileDepth = 4096;
+ var source = new string('(', hostileDepth)
+ + "Get-Date"
+ + new string(')', hostileDepth);
+
+ var result = Parse(source);
+
+ Assert.True(result.IsUnparseable);
+ Assert.Empty(result.Commands);
+ Assert.Empty(result.Clauses);
+ Assert.Contains("nesting depth", result.UnparseableReason!);
+ }
+
+ [Fact]
+ public void PowerShell_host_wrapper_preserves_inner_structure_in_an_isolated_group()
+ {
+ var result = Parse(
+ "pwsh -Command \"Get-Item x | Select-Object Name; Get-Date\"");
+
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(ShellGroupKind.IsolatedScope, wrapper.GroupKind);
+ Assert.Equal(0, wrapper.SourceStart);
+ Assert.Equal(result.Source.Length, wrapper.SourceLength);
+ Assert.Null(wrapper.Body.SourceStart);
+ Assert.Null(wrapper.Body.SourceLength);
+ var list = Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.IsType(list.Items[0].Command);
+ Assert.Equal(3, result.Commands.Count);
+ Assert.All(result.Clauses, clause => Assert.True(clause.IsCommandStringWrapped));
+ Assert.All(Descendants(wrapper.Body), node =>
+ {
+ Assert.Null(node.SourceStart);
+ Assert.Null(node.SourceLength);
+ });
+
+ var leaves = Descendants(wrapper.Body).OfType().ToArray();
+ Assert.Equal(result.Commands.Count, leaves.Length);
+ for (var index = 0; index < leaves.Length; index++)
+ {
+ Assert.Same(leaves[index].Clause, result.Commands[index].Clause);
+ Assert.Same(leaves[index].Clause, result.Clauses[index]);
+ }
+ }
+
+ [Fact]
+ public void Invoke_expression_wrapper_preserves_inner_structure_in_current_scope()
+ {
+ var result = Parse("iex 'Get-Date; Get-Process'");
+
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(ShellGroupKind.CurrentScope, wrapper.GroupKind);
+ Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.Equal(2, result.Commands.Count);
+ Assert.All(result.Commands, command => Assert.True(command.IsComplete));
+ }
+
+ [Theory]
+ [InlineData("Get-Date || pwsh -Command \"Get-Item x; Get-Process\"")]
+ [InlineData("Get-Date || iex 'Get-Item x; Get-Process'")]
+ public void Operator_entering_static_wrapper_is_carried_by_its_first_leaf(string source)
+ {
+ var result = Parse(source);
+
+ var list = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.IsType(list.Items[1].Command);
+ Assert.Equal(
+ new[]
+ {
+ CompoundOperator.None,
+ CompoundOperator.OrIf,
+ CompoundOperator.Sequence,
+ },
+ result.Clauses.Select(clause => clause.Operator));
+ }
+
+ [Fact]
+ public void Invoke_expression_exports_location_changes_to_following_commands()
+ {
+ var result = Parse(
+ "iex 'Set-Location C:\\sensitive'; Remove-Item child.txt");
+
+ var remove = result.Clauses.Last();
+ Assert.Contains(
+ remove.Args,
+ arg => arg.IsCwdAttribution && arg.Resolved == "C:/sensitive");
+ }
+
+ [Fact]
+ public void Terminal_outer_redirect_keeps_exact_element_span_on_last_wrapped_leaf()
+ {
+ const string source = "pwsh -Command \"Get-Date; Get-Process\" > out.txt";
+
+ var result = Parse(source);
+
+ Assert.Equal(2, result.Commands.Count);
+ Assert.True(result.Commands[0].IsComplete);
+ Assert.False(result.Commands[1].IsComplete);
+ var last = result.Clauses[1];
+ Assert.Single(last.Redirects);
+ var redirect = last.Elements.Last();
+ Assert.NotNull(redirect.SourceStart);
+ Assert.Equal("> out.txt", source.Substring(
+ redirect.SourceStart!.Value,
+ redirect.SourceLength!.Value));
+ Assert.All(last.Elements.Take(last.Elements.Count - 1), element =>
+ {
+ Assert.Null(element.SourceStart);
+ Assert.Null(element.SourceLength);
+ });
+ }
+
+ [Theory]
+ [InlineData("& $exe arg")]
+ [InlineData("Invoke-Expression $code")]
+ [InlineData("pwsh $flag payload")]
+ [InlineData("pwsh $flag -Command \"Get-Date\"")]
+ [InlineData("pwsh \"-NoProfile\" -Command \"Get-Date\"")]
+ [InlineData("pwsh \"-Command\" \"Get-Date\"")]
+ [InlineData("pwsh --% -Command Get-Date")]
+ [InlineData("pwsh -CommandWithArgs 'Get-Date'")]
+ [InlineData("pwsh -cwa 'Get-Date'")]
+ [InlineData("pwsh -Command -")]
+ public void Undiscovered_command_identity_or_payload_is_incomplete(string source)
+ {
+ var result = Parse(source);
+
+ Assert.False(result.IsUnparseable);
+ Assert.False(Assert.Single(result.Commands).IsComplete);
+ }
+
+ [Fact]
+ public void Exact_static_host_prefix_still_allows_structural_recursion()
+ {
+ var result = Parse("pwsh -NoProfile -Command \"Get-Date\"");
+
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ Assert.Equal(ShellGroupKind.IsolatedScope, wrapper.GroupKind);
+ Assert.True(Assert.Single(result.Commands).IsComplete);
+ Assert.Equal("Get-Date", Assert.Single(result.Clauses).Verb.Tokens[0]);
+ }
+
+ [Fact]
+ public void Undiscovered_subexpression_is_incomplete_but_ordinary_variable_data_is_not()
+ {
+ var hidden = Parse("Write-Output $(Get-Date)");
+ var data = Parse("Write-Output $value");
+
+ Assert.False(Assert.Single(hidden.Commands).IsComplete);
+ Assert.True(Assert.Single(data.Commands).IsComplete);
+ }
+
+ [Fact]
+ public void Empty_wrapper_body_with_redirect_preserves_the_compatibility_leaf()
+ {
+ var result = Parse("pwsh -Command \"\" > out.txt");
+
+ var wrapper = Assert.IsType(Assert.Single(result.Syntax.Statements));
+ var leaf = Assert.IsType(Assert.Single(wrapper.Body.Statements));
+ Assert.Same(leaf.Clause, Assert.Single(result.Clauses));
+ Assert.False(Assert.Single(result.Commands).IsComplete);
+ Assert.Single(leaf.Clause.Redirects);
+ }
+
+ private static ParsedCommand Parse(string source) => new PwshParser(
+ new PwshParserOptions
+ {
+ HomeDirectory = "C:/Users/test",
+ WorkingDirectory = "C:/work",
+ }).Parse(source);
+
+ private static IEnumerable Descendants(ShellSyntaxNode node)
+ {
+ yield return node;
+ switch (node)
+ {
+ case ShellBlockSyntax block:
+ foreach (var statement in block.Statements)
+ {
+ foreach (var descendant in Descendants(statement))
+ {
+ yield return descendant;
+ }
+ }
+
+ break;
+ case CommandListSyntax list:
+ foreach (var item in list.Items)
+ {
+ foreach (var descendant in Descendants(item.Command))
+ {
+ yield return descendant;
+ }
+ }
+
+ break;
+ case PipelineSyntax pipeline:
+ foreach (var stage in pipeline.Stages)
+ {
+ foreach (var descendant in Descendants(stage))
+ {
+ yield return descendant;
+ }
+ }
+
+ break;
+ case GroupSyntax group:
+ foreach (var descendant in Descendants(group.Body))
+ {
+ yield return descendant;
+ }
+
+ break;
+ }
+ }
+}
diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs
index 600ea23..ce8ccd7 100644
--- a/tools/PwshCorpusTool/CorpusManifest.cs
+++ b/tools/PwshCorpusTool/CorpusManifest.cs
@@ -371,8 +371,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."),
- E("dynamic_call_scriptblock", "& { Get-Date }",
- "The call operator on a script block — dynamic command."),
+ 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."),
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",