From f312e6489c9d78b771b04b41ab660b85c376449e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 04:11:31 +0000 Subject: [PATCH] Adapt PowerShell parser to structural projections --- IMPLEMENTATION_PLAN.md | 13 +- SPEC.POWERSHELL.md | 14 + .../executable-command-projection/spec.md | 10 + .../v0-3-structured-shell-analysis/tasks.md | 4 +- .../Pwsh/Parsing/PwshCommandParser.cs | 364 ++----- .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 979 ++++++++++++++++++ .../162_dynamic_call_scriptblock.json | 17 +- .../DesignCorpus/v0.3/powershell.json | 3 +- .../Parsing/PwshStructuralProjectionTests.cs | 367 +++++++ tools/PwshCorpusTool/CorpusManifest.cs | 4 +- 10 files changed, 1497 insertions(+), 278 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs 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",