Skip to content

Latest commit

 

History

History
1987 lines (1696 loc) · 105 KB

File metadata and controls

1987 lines (1696 loc) · 105 KB

ShellSyntaxTree — PowerShell Specification (through v0.3)

Status: v0.2.0 shipped; the accepted v0.3 contract adds bounded PowerShell foreach structure plus shared command-occurrence and explicit redirect analysis. Audience: Whoever (human or agent) implements, consumes, or maintains the ShellSyntaxTree PowerShell parser. Read SPEC.md (the bash and shared-contract specification) end-to-end first — this document specifies only what differs for PowerShell.

This document specifies the PowerShell parser through ShellSyntaxTree v0.3: its grammar, tokenization, cmdlet/verb tables, alias resolution, resolver semantics, bounded control-flow analysis, and corpus contract. PowerShell reuses the shared public API defined in SPEC.md §2–§3.

It is not a PowerShell interpreter. It does not execute, expand, or evaluate commands. It returns the same structured AST a consumer already walks for bash. The parsing scope is Pipeline-aware (§4): linear command pipelines parse; stable v0.3 also supports only the explicitly bounded foreach subset below. Other script-level constructs mark IsUnparseable.

SPEC.md is the canonical home of the shared public API, AST, sanitization workflow, and consumer contract. Where this spec says "see SPEC.md §N" the referenced section applies unchanged; only the PowerShell-specific delta is written here.

Explicit dialects. Existing callers default to PowerShell 7.6 servicing releases from 7.6.4 (>=7.6.4 and <7.7), the cross-platform pwsh executable and the dialect shipped through v0.2. Stable v0.3 also accepts an explicit Windows PowerShell 5.1 dialect for the native Windows fallback. The parser never infers an edition from the local machine. The && / || pipeline-chain operators (§4), PowerShell 7 aliases and receiver parameters, and other versioned metadata apply only in PowerShell 7 mode. Each corpus entry is validated by its matching oracle: pwsh for 7.x and powershell.exe for 5.1 on Windows.


1. Goals & Non-Goals

Goals (v0.2.0)

  1. Parse PowerShell command pipelines into the shared ParsedCommand AST — per-clause verbs, args, parameters, redirects, compound operators — exactly the shape bash already produces.
  2. Recognize PowerShell cmdlets (Verb-Noun), native commands, and built-in aliases; resolve aliases to their canonical cmdlet while preserving the verbatim typed token.
  3. Extract paths with per-cmdlet and per-parameter knowledge (-Path, -LiteralPath, -Destination, positional rules).
  4. Honor Set-Location <dir>; cmd propagation — subsequent clauses see <dir> as cwd, mirroring bash cd (SPEC.md §9).
  5. Recurse into pwsh -Command "<inner>", pwsh -c, pwsh -EncodedCommand <base64>, and provably static Invoke-Expression / iex payloads so inner command clauses surface to the consumer.
  6. Mark dynamic-content tokens ($var, subexpressions, script blocks, splatting) with explicit DynamicSkip / IsPath=false.
  7. Implement PwshParser : IShellParser alongside BashParser — the multi-shell seam from SPEC.md §1 is exercised for the first time.

Non-Goals (v0.2.0)

  • Parsing PowerShell scripts — control flow (if/foreach/while/ switch), function/filter/class/enum definitions, param()/ begin/process/end blocks, trap, DATA sections. These mark IsUnparseable (§11).
  • Parsing .ps1 script files. pwsh -File script.ps1 parses as an ordinary clause; the file content is not read.
  • Evaluating PowerShell expressions, the pipeline variable $_ / $PSItem, or .NET method calls.
  • Desired State Configuration (DSC).
  • Windows cmd parsing (still deferred — see SPEC.md §18).
  • Command execution and variable expansion — the same non-goals as bash (SPEC.md §1). The library marks dynamic tokens; it never resolves them.

v0.3 extension

Stable v0.3 adds structured projection for bounded foreach statements; exposes iterator and body commands exactly once; derives exact or finite string values only from proved literal iterables; and joins location and supported binding state conservatively. Pipeline-produced objects and unsupported expressions remain unknown without execution. while, if, elseif, else, do, switch, definitions, and arbitrary script evaluation stay outside the supported grammar.


2. Public API Surface

The shared interface, AST records, and enums are defined in SPEC.md §2. The additive Clause.Elements, ClauseElement, and ClauseElementRole provenance surface and the v0.3 syntax, occurrence, value-domain, and explicit redirect types apply identically to both parsers. PowerShell adds the following parser types to namespace ShellSyntaxTree; everything else is internal.

namespace ShellSyntaxTree;

/// <summary>
/// Shell-neutral configuration shared by every IShellParser implementation.
/// Carries the resolver knobs used to expand and normalize path tokens.
/// </summary>
public abstract record ShellParserOptions
{
    /// <summary>User home directory for ~ / $HOME / $env:USERPROFILE
    /// expansion. Defaults to Environment.SpecialFolder.UserProfile.</summary>
    public string? HomeDirectory { get; init; }

    /// <summary>Working directory for relative-path resolution. Defaults to
    /// the daemon-process cwd.</summary>
    public string? WorkingDirectory { get; init; }
}

/// <summary>Bash configuration. The v0.1 properties move to the base record;
/// the shape stays source-compatible — `new BashParserOptions { HomeDirectory
/// = ... }` still compiles.</summary>
public sealed record BashParserOptions : ShellParserOptions;

/// <summary>Compatibility option for PowerShell initial host-state analysis.</summary>
public enum PwshInitialStateMode
{
    Unknown,
    IsolatedNonInteractiveNoProfile,
}

/// <summary>Selects the PowerShell grammar and versioned metadata.</summary>
public enum PwshDialect
{
    Unknown,
    // PowerShell 7.6 servicing releases from 7.6.4.
    PowerShell7,
    WindowsPowerShell51,
}

/// <summary>Configuration knobs for PwshParser.</summary>
public sealed record PwshParserOptions : ShellParserOptions
{
    public PwshInitialStateMode InitialStateMode { get; init; }
    public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7;
}

/// <summary>PowerShell implementation of IShellParser.</summary>
public sealed class PwshParser : IShellParser
{
    public PwshParser();
    public PwshParser(PwshParserOptions options);
    public ParsedCommand Parse(string command);
}

The shared v0.2 AST gains the following changes (see §3):

  • VerbChain gains an additive string? CanonicalVerb field.
  • VerbChain gains an additive bool IsDynamic field.
  • Clause gains the additive Elements provenance view shared with Bash; ClauseElement and ClauseElementRole define its entries.
  • Clause.IsBashCWrapped is renamed Clause.IsCommandStringWrapped.

Versioning. PwshParser, PwshParserOptions, PwshDialect, ShellParserOptions, VerbChain.CanonicalVerb, VerbChain.IsDynamic, Clause.Elements, ClauseElement, and ClauseElementRole are additive. The Clause field rename and the BashParserOptions reparenting are breaking; SPEC.md Appendix A permits a breaking AST change on a 0.x minor bump when RELEASE_NOTES.md carries the old→new mapping and Netclaw is updated in lockstep (§15). PublicApiSnapshotTests is updated in the same change. PwshParser.Parse throws ArgumentNullException on null input and never throws on a well-formed string, exactly like BashParser. Adding Dialect is source- and binary-additive and preserves default parser semantics, but it participates in generated record equality, hashing, ToString(), reflection, and default serialization. Consumers that persist options own a versioned representation.

Dialect and host-language boundary

The consumer selects the top-level dialect from the executor it has already chosen. new PwshParser() and an options object that omits Dialect retain PowerShell 7 behavior. Unknown and unrecognized future enum values return an unparseable result with empty authorization projections. PowerShell7 denotes the contract-defined PowerShell 7.6 servicing line from 7.6.4: >=7.6.4 and <7.7. The consumer verifies both bounds before using the dialect. Other PowerShell 7 minor lines have no v0.3 dialect value and remain outside the supported execution contract until their grammar and versioned metadata are independently proved.

PwshParser never delegates bash -c payloads to BashParser; Bash is an ordinary external command in PowerShell source. Conversely, BashParser never delegates pwsh -Command payloads to this parser. Within a PowerShell parse, supported static PowerShell host wrappers remain parser-local: pwsh / pwsh.exe children use PowerShell7, and powershell / powershell.exe children use WindowsPowerShell51. A dynamic host identity remains incomplete.

PowerShell 7 keeps the existing version-pinned grammar and catalogs. Windows PowerShell 5.1 accepts only facts proved for that edition. It rejects && and || as syntax errors at every recursively parsed boundary, including loop and script-block bodies, substitutions, static expressions, and decoded child-host payloads. It does not publish PowerShell 7-only receiver semantics such as ForEach-Object -Parallel. Dialect-specific aliases and parameter bindings come from versioned tables; one edition never borrows proof from the other. In particular, unqualified curl and wget use the Windows PowerShell 5.1 Invoke-WebRequest aliases but remain native spellings in PowerShell 7.


3. AST Reference

The AST records and enums are defined in SPEC.md §3 and are emitted by PwshParser under the same shared contract — a consumer walks a PowerShell ParsedCommand exactly as it walks a bash one. PowerShell has the following deltas and provenance rules:

Clause.Elements PowerShell rules

PowerShell parameters, native options, quoted/here-string values, and opaque dynamic regions each occupy their authored position in Clause.Elements. The leading call operator in & command and grouping parentheses are shell syntax rather than verb/argument/redirect leaves and do not appear.

Inline parameter forms remain one source element. For -Path:C:\repo, the element's Raw and Value describe the full parameter token while Kind, IsPath, and Resolved describe the bound C:\repo value. Native --flag=value follows the same rule as Bash. Backtick escapes in an inline bound value are decoded before Value and path metadata are produced. Adjacent native fragments such as --data='@C:\payload file' form one element because PowerShell passes them to the executable as one argument. The complete contiguous fragment run is consumed. Resolver-sensitive syntax inside a single-quoted fragment mixed with expandable fragments safe-fails as DynamicSkip rather than being expanded.

Clauses recursively surfaced from pwsh -Command and pwsh -EncodedCommand retain inner Raw and Value but have null SourceStart and SourceLength: quote/backtick processing, script-block stripping, and base64 decoding do not provide a generally exact map into the outer ParsedCommand.Source. An outer redirect authored after a pwsh -Command or -EncodedCommand payload remains on the surfaced wrapped clause with its exact outer source span; only decoded inner elements have null spans.

ClauseElement.Role and PrecedingVerbElementCount mirror the shared greedy native verb projection. They are AST coordinates, not native-executable semantic boundaries. A PowerShell consumer applies executable-specific grammar to the complete authored element order exactly as a Bash consumer does.

PowerShell cmdlet names, aliases, and parameter names remain case-insensitive. Native option spelling is ordinal and reuses the shared Bash native tables unchanged: PowerShell does not make a native executable's -c and -C options equivalent.

VerbChain.CanonicalVerb (new, additive)

/// <summary>
/// The canonical, alias-resolved verb identity, set when the parser resolved
/// the first token of Tokens from a shell built-in alias — e.g. `ls` / `gci`
/// / `dir` resolve to `Get-ChildItem`; `rm` / `del` resolve to
/// `Remove-Item`. Tokens always keeps the verbatim token the user typed;
/// this field carries the resolved name so a consumer can gate on canonical
/// identity without re-implementing the alias table.
///
/// Null when no alias resolution applied: every bash clause, and every
/// PowerShell clause whose verb is already a canonical cmdlet or an unknown
/// command. Consumers SHOULD use `CanonicalVerb ?? Tokens[0]` as the gate key.
/// </summary>
public string? CanonicalVerb { get; init; }

CanonicalVerb is non-null only when an alias was rewritten. A user who types the canonical cmdlet (Get-ChildItem) leaves it null — a non-null value unambiguously signals "an alias was expanded."

VerbChain.IsDynamic (new, additive)

/// <summary>
/// True when the clause's command name is a dynamic token the parser
/// cannot statically identify — a variable (`& $exe`), an interpolated name,
/// or a supported subexpression (`& $(Get-Thing)`) at verb position. Tokens
/// still carries the verbatim token; CanonicalVerb is null. An unsupported
/// executable identity expression makes the whole result unparseable with
/// empty command and compatibility projections instead.
///
/// A consumer MUST treat a clause with IsDynamic=true as "the command being
/// run is unknown" and route to safe-fail — the verb identity, and
/// therefore every verb-keyed gate rule, is unresolvable. Always false for
/// bash clauses and for PowerShell clauses with a literal command name.
/// </summary>
public bool IsDynamic { get; init; }

IsDynamic exists because PowerShell's call operator (&) makes invoking a dynamically-named command a first-class, common idiom. A clause whose verb is $exe or an interpolated name such as "tool-$name" is otherwise indistinguishable from one whose verb is a literal — and "we do not know what is being executed" is the most security-relevant state the AST can carry, so it gets a field rather than being silently flattened into Tokens. The clause's args and redirects still parse normally so a consumer sees the rest of the shape.

Clause.IsBashCWrappedClause.IsCommandStringWrapped

The v0.1 field IsBashCWrapped is renamed IsCommandStringWrapped. The meaning is unchanged and now shell-neutral: true when this clause is the result of recursing into a command-string wrapper — bash bash -c "..." / sh -c "...", or PowerShell pwsh -Command "..." / pwsh -c "..." / pwsh -EncodedCommand ... / static Invoke-Expression '...' (§10). The property is shared; recursion is not cross-language. BashParser sets it only for supported Bash wrappers and PwshParser only for supported PowerShell wrappers.


4. Grammar

Approximate BNF for what the PowerShell parser accepts. Anything outside this grammar marks ParsedCommand.IsUnparseable = true (§11). PowerShell is case-insensitive — keywords, cmdlet names, aliases, parameter names, and drive qualifiers are all matched case-insensitively.

command          := statement (statement_sep statement)*
statement_sep    := ";" | "&&" | "||" | NEWLINE
statement        := pipeline
pipeline         := pipeline_element ("|" pipeline_element)*
pipeline_element := static_invocation | dynamic_invocation
                  | direct_script_block_invocation
                  | supported_subexpression | grouped_pipeline
static_invocation := call_op? command_name arg* redirect*
dynamic_invocation := call_op dynamic_command_name arg* redirect*
call_op          := "&"                        // call operator at verb position
direct_script_block_invocation := ("&" | ".") script_block
command_name     := cmdlet | native_word          // statically identified;
                                                // excludes variables,
                                                // quoted expressions, and $()
arg              := parameter | value
parameter        := "-" param_name (":" value)?     // -Name value | -Name:value
                  | "-" param_name                   // switch parameter
                  | "--"                              // end-of-parameters marker
value            := word | quoted_string | here_string
                  | script_block         // DynamicSkip Arg plus a proved,
                                         // unknown, or absent execution region
                  | supported_subexpression // $( ... ) -> child commands + DynamicSkip Arg
                  | array_expression     // @( ... )  -> DynamicSkip Arg
                  | hash_literal         // @{ ... }  -> DynamicSkip Arg
                  | splat                // @var      -> DynamicSkip Arg
redirect         := redirect_op target
redirect_op      := ">" | ">>"
                  | STREAM ">" | STREAM ">>"          // STREAM in {1..6, *}
                  | MERGE_SOURCE ">&1"                 // MERGE_SOURCE in {2..6, *}
target           := word | quoted_string | supported_subexpression | "$null"
supported_subexpression := "$(" command ")"
dynamic_command_name := variable | quoted_string | supported_subexpression
variable         := "$" identifier | "${" variable_name "}"
                  | "$env:" identifier
grouped_pipeline := "(" pipeline ")"                 // parenthesized sub-pipeline
word             := run of non-whitespace, non-operator, non-quote chars,
                    honoring backtick escape; absorbs $var / ${name} /
                    $env:NAME / drive-qualified path prefixes
quoted_string    := single_quoted | double_quoted
                    // double-quoted and expandable here-string values may
                    // contain supported_subexpression children; literal or
                    // backtick-escaped spellings do not

Notes:

  • Whitespace between tokens is one or more spaces or tabs.
  • A bare newline outside quotes, here-strings, { }, $( ), @( ), @{ }, grouping ( ), and line continuations is a statement separator equivalent to ;CompoundOperator.Sequence. Consecutive, leading, and trailing newlines collapse; a newline immediately after |, &&, or || collapses (a pipeline may continue on the next line).
  • Backtick ` followed by a newline is a line continuation (treated as whitespace) — the PowerShell analog of bash \ + newline.
  • & at verb position is the call operator (& git status, & $exe, & { ... }). A direct & { ... } is a child-scope execution region; direct . { ... } is a current-scope dot-source execution region. Neither operator becomes a synthetic command occurrence. Dot-sourcing a file remains unparseable because the file contents are unavailable. A trailing & (a PowerShell background job) marks IsUnparseable (§11).
  • Under v0.2 a variable, subexpression, quoted string, or script block at command position could be retained as a dynamic clause. Stable v0.3 aligns this with PowerShell invocation semantics: only & <dynamic-expression> creates an outer dynamic command occurrence. Standalone $() is an expression statement whose inner commands are exposed without inventing an invocation of its produced value. Standalone $() followed by command-style 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 propagates through ( ... ) rather than being isolated by it (§9). A group containing executable syntax outside the bounded v0.3 grammar marks the whole result IsUnparseable.
  • --% is the stop-parsing token: the remainder of the line — to the next newline or end of input, including any |, ;, &&, or ||, which become literal text rather than operators — becomes one opaque DynamicSkip arg. --% does not stop at a pipeline-element boundary; treating | cmd after --% as a new clause would invent a clause that does not exist.
  • The lexer preserves script blocks { ... }, subexpressions $( ... ), array @( ... ), and hash @{ ... } literals as bounded tokens. Under v0.2 each was one opaque DynamicSkip arg. Stable v0.3 recursively parses every completely delimited executable $() in a supported value position and exposes its commands while retaining the containing authored DynamicSkip leaf. Script blocks are classified after command and parameter binding: cataloged execution-bearing bindings create typed regions, cataloged data bindings remain opaque, and unknown receivers create unknown incomplete regions whose supported non-pipeline bodies remain visible. An unproved pipeline inside such a region fails atomically. An @() or @{} value with execution-bearing content is unparseable until that expression form has complete command discovery; a non-executing literal form may remain an opaque value.
  • Under v0.2, control-flow keywords fall outside the grammar. Stable v0.3 owns only the contextual statement forms below. Definition keywords, unsupported block keywords, param(), assignment statements, bare [type]::member calls, and bare arithmetic at statement position remain unparseable (§11).

v0.3 structured PowerShell grammar

PowerShell retains a statement-versus-pipeline distinction. foreach is a language keyword only at statement position when followed by (; Get-ChildItem | foreach { ... } and Write-Output x | foreach ($_) remain command/alias syntax. An ordinary bounded non-executing parenthesized argument remains opaque. A script-block argument is classified by its proved receiver and parameter binding: executing bindings create regions, proved data remains opaque, and unknown receivers create unknown incomplete regions for supported non-pipeline bodies. An unproved interior pipeline fails atomically. It is not reinterpreted as a loop body. && and || join pipelines, not control-flow statements, so they cannot precede or follow foreach; ; and newline remain legal statement terminators.

pwsh_script(stop)    := pwsh_statement (statement_terminator pwsh_statement)*
pwsh_statement       := pwsh_foreach
                      | pwsh_and_or

statement_terminator := ";" | NEWLINE
pwsh_and_or           := pwsh_pipeline
                        (("&&" | "||") pwsh_pipeline)*
pwsh_pipeline         := pipeline_element ("|" pipeline_element)*

pwsh_foreach         := "foreach" "(" variable "in" foreach_expression ")"
                        script_block_body

foreach_expression   := literal_value
                      | literal_array
                      | pipeline_expression
                      | supported_subexpression
literal_array        := "@(" literal_value ("," literal_value)* ")"
script_block_body    := "{" pwsh_script(stop = "}") "}"
direct_execution_region := ("&" | ".") script_block_body

Literal scalar and literal-array iterables may produce exact or finite string domains. A pipeline iterable exposes every producing command with role Iterator, but its object values remain Unknown; the parser does not predict PowerShell object-to-string conversion. A body is recursively parsed after the structural grammar proves that the ScriptBlock token is a statement body, direct execution region, cataloged execution-bearing argument, or conservative unknown-receiver region. A proved non-executing script-block argument remains one opaque DynamicSkip value and does not invent child execution.

PwshInitialStateMode.Unknown remains the default and does not publish exact or finite loop-dependent effective values. An ambient typed, validated, read-only, or constant binding may coerce or reject the assignment, so authored iterable text is not a proved runtime argument. The surrounding static command occurrence may still be complete because value precision is independent.

IsolatedNonInteractiveNoProfile asserts that the complete source runs in a newly spawned noninteractive PowerShell process with profiles disabled and no reused or caller-initialized runspace. It permits exact or finite values for ordinary unscoped bindings. It does not assert a pinned module, alias, function, PATH, or executable-resolution baseline; those runtime externalities are outside authored-command completeness.

Only ordinary unscoped binding names that do not case-insensitively collide with PowerShell's automatic, constant, read-only, preference, or configuration variables are eligible. Scoped/provider bindings such as $global:x, $script:x, $private:x, and $env:X fail the loop region closed. These special names are visible in the submitted source and can change host behavior. Typed and validated ambient bindings are the reason default-mode effective values stay Unknown; the parser never reports authored text as a proved runtime value when coercion or rejection is possible.

Parenthesized groups, $(), and static Invoke-Expression execute in the current runspace and share supported authored binding and location state. Decoded pwsh -Command and pwsh -EncodedCommand payloads do not inherit exact $HOME, environment, provider, or cwd facts unless those facts are independently proved. They do retain complete static authored command occurrences. Path-shaped native or .ps1 spellings use their authored binding classification; ambient alias, function, module, profile, and executable resolution is outside the approval-grammar proof.

Recognized source-level variable, alias, function, or module mutation invalidates later affected proofs in every observing scope. A computed or otherwise hidden invocation remains incomplete and may invalidate later state; an unmodeled explicit pipeline writer fails atomically. Cwd-only mutation retains independent authored-binding facts.

Mutation is recognized from the effective parameter vector as well as the verb. Common parameter writers -OutVariable / -ov, -PipelineVariable / -pv, -ErrorVariable / -ev, -WarningVariable / -wv, and -InformationVariable / -iv invalidate later observing proofs, including accepted unambiguous prefixes such as -OutV and -PipelineV and inline forms such as -ov:name. Command-specific writers include Tee-Object -Variable, Import-LocalizedData -BindingVariable / -Variable, Invoke-RestMethod -SessionVariable / -SV, -ResponseHeadersVariable / -RHV, and -StatusCodeVariable, plus Invoke-WebRequest -SessionVariable / -SV; their accepted unambiguous prefixes have the same effect. An opaque splat may supply any such parameter and is therefore a possible mutation. Because command type and custom advanced-function metadata are runtime facts, an otherwise unclassified command carrying one of the common writer forms is treated conservatively rather than assumed native. When Set-Location carries a recognized writer, the writer effect composes with both its success and failure cwd outcomes. In particular, a failure-gated continuation after -ErrorVariable cannot retain a proved prior value merely because location analysis selected the failure partition.

A completely delimited $() used as an ordinary word, dynamic command identity after &, redirect value, foreach expression, double-quoted interpolation, or expandable here-string is recursively parsed as a command substitution. Its commands are exposed before the containing command and its produced value is Unknown. Single-quoted strings, literal here-strings, and backtick-escaped $() text never create substitution nodes. An unsupported execution-bearing expression makes the whole result unparseable rather than leaving hidden commands inside a DynamicSkip value.

A standalone $() statement has no containing simple command: the syntax block contains the CommandSubstitutionSyntax directly and Commands contains only commands from its body. & $(...) additionally retains one incomplete dynamic outer occurrence after all substitution commands. Bash differs: an unquoted command substitution in Bash command-name position contributes to the command word, so stable v0.3 makes that runtime-dependent identity unparseable rather than inventing a static or PowerShell-style dynamic clause.

Script-block execution regions

& { ... } and . { ... } are direct execution regions. Their bodies are recursively parsed and no synthetic outer command occurrence is created for the invocation operator. Their typed origins are DirectCall and DotSource respectively, so consumers never need to reparse source text to distinguish their state behavior. & executes once synchronously in a child variable/command scope while sharing runspace location; . executes once synchronously in the current scope. Dot-sourcing a file remains unparseable because the parser does not read .ps1 contents.

A script block passed to a command remains an authored DynamicSkip argument on the host Clause. After canonical command and parameter binding, a proved execution-bearing block additionally creates an attached ExecutionRegionSyntax; a proved data block does not. An unknown receiver or ambiguous binding conservatively creates an unknown incomplete region. Every command in a completely parsed supported non-pipeline body remains visible. If the body cannot be parsed completely, or it contains a pipeline whose stage identity is unproved, the whole result is unparseable.

The version-pinned PowerShell 7 catalog covers:

Receiver / parameter Phase Timing Cardinality State boundary
direct & {} Main Synchronous Once child variables/commands; shared location
direct . {} Main Synchronous Once current scope and location
ForEach-Object -Begin Begin Synchronous Once current runspace
ForEach-Object -Process / -RemainingScripts Process, with binder-assigned Begin/End where applicable Synchronous OncePerInputObject current runspace
ForEach-Object -End End Synchronous Once current runspace
ForEach-Object -Parallel Process Concurrent OncePerInputObject child runspace; runspace-local exit isolated; process-wide effects conservative
Where-Object -FilterScript Filter Synchronous OncePerInputObject current runspace
in-process Invoke-Command -ScriptBlock Main Synchronous Once child scope unless -NoNewScope; shared location
remote/session/SSH/VM/container Invoke-Command Main Synchronous for one proved target; Concurrent for multiple targets or enabled -AsJob / -InDisconnectedSession; otherwise Unknown Once for one proved target; otherwise Unknown arbitrary remote state; exit isolated
Measure-Command -Expression, Trace-Command -Expression Main Synchronous Once current scope and location
Start-Job -InitializationScript Initialization Concurrent Once child process before Main
Start-Job -ScriptBlock Main Concurrent Once child process; exit isolated
New-Module -ScriptBlock Initialization Synchronous Once module state; current-runspace effects analyzed separately

For WindowsPowerShell51, direct & { ... } and . { ... } retain the grammar- and state-proved semantics above, and a statically authored Write-Output { ... } remains proved non-executing data. Other command-owned script blocks remain visible but incomplete with Unknown execution and state facts until their 5.1 receiver and binder metadata is independently oracle-proved. This deliberately prevents the 5.1 dialect from inheriting the PowerShell 7 catalog merely because command spellings overlap.

Remote Invoke-Command bodies begin with Unknown working directory and host-dependent values. Their static authored command occurrences remain complete; local parser state is not an inheritance proof for a remote host or persistent session, and remote exit state never flows into the invoking host continuation. A complete literal, quoted, URI, GUID, or hashtable target proves one activation. A complete top-level comma-separated target list, whether named, inline, or positional, proves concurrent scheduling but maps to public cardinality Unknown, because the public enum intentionally has no once-per-target value. Quoted, backtick-escaped, or nested commas remain part of one target value. A dynamic target or session collection leaves both timing and cardinality Unknown unless an enabled -AsJob or -InDisconnectedSession independently proves concurrent scheduling. Explicit :$false switch values do not prove concurrency.

Parallel child runspaces do not inherit ordinary caller variables or aliases, and their runspace-local variable and location exit state does not flow into the host continuation. They do share process-wide state such as the environment provider. A supported or unknown child mutation that may affect such state invalidates later host binding, command-resolution, and location facts. It also invalidates later child-activation facts even with -UseNewRunspace, because a fresh runspace is not a fresh process. Runspace global: variable, function, alias, and location state remains local to that runspace and does not by itself invalidate the host. Once child command resolution is mutated, however, an exact changed alias or function name is retained in bounded case-insensitive state. A later matching invocation whose identity is no longer proved is a possible process-wide mutation and receives the same conservative invalidation unless its identity is independently proved. An ambiguous changed name, wildcard, or candidate-set overflow collapses to all unproved command names rather than guessing.

Optional-module Start-ThreadJob and deferred breakpoint, event, and argument- completion receivers are not stable-v0.3 catalog-completeness promises. Existing conservative recognition may remain, but additional module/version or trigger-time proof does not gate the release. Every unproved form follows the unknown-receiver rule: supported non-pipeline bodies remain visible with incomplete execution and state facts, while an unproved interior pipeline fails atomically.

Aliases, supported module-qualified spellings, static call-operator spellings, parameter abbreviations and inline values, positional binding, parameter-set selection, and ScriptBlock[] binding resolve through the same static catalog. Catalog lookup classifies the authored command for approval; it is not a claim about ambient runtime resolution. A catalog entry may classify a script block as non-executing data unless an explicit source-level mutation has invalidated that authored receiver proof. Mutation matching covers both the authored spelling and its known canonical alias target. PowerShell's special multiple-script-block binding for ForEach-Object assigns Begin, Process, and End phases semantically; authored syntax and occurrence projection remain in source order while the analyzer schedules phases in runtime order.

ExecutionRegionTiming and ExecutionRegionCardinality are not scope facts. Variable, location, command-resolution, runspace, and process propagation are analyzed independently. Cataloged receivers may retain already-proved scheduling facts without making further catalog expansion release-gating. Unproved receiver, binding, or authored state facts remain Unknown rather than borrowing runtime state. A static authored Write-Output { Remove-Item x } remains opaque data and does not invent a Remove-Item occurrence.

A leading param(...) declaration inside any execution region remains outside the stable-v0.3 body grammar and makes the whole parse unparseable. This deliberately limits realistic argument-completer and directly invoked blocks until parameter declaration and block-argument binding are modeled together.

PowerShell scope and location state remain shell-specific. Grouping ( ... ) does not isolate location. Foreach exits include the zero-iteration state. The parser does not publish a finite cwd set.

foreach assignments use a case-insensitive persistent binding map rather than lexical push/pop restoration. A proved nonempty ordered iterable leaves its last assigned value after the loop; a same-name nested loop overwrites that value. A proved empty iterable performs no body transition and preserves the incoming binding and location. A zero-or-more iterable joins the zero path with all reachable iteration exits. Repeated visits join facts for each authored occurrence, and all concrete or fixed-point visits share the parse-wide 4096 transition budget.

Set-Location has separate success and failure transfers: success takes the proved filesystem target location, while failure retains the incoming location. A successful non-filesystem or unproved target invalidates binding and command-resolution proofs in addition to making cwd unknown. && continues from success, || from failure, and statement sequence consumes their join. $() and parenthesized groups share current-runspace state; decoded child hosts isolate their exit state. Unsupported directory-stack or state/command-resolution mutations, including Import-Alias, Import-PSSession, and New-Module, remain fail closed. Provider-capable item mutators invalidate binding proofs when their target provider is not proved; dynamic values alone do not invalidate a proved filesystem target. Loop parsing uses cloned compatibility attribution so an unreachable body cannot leak a parse-time location, and a possibly reached mutation cannot leave a false exact path. Outcome projection also rebases cwd-dependent compatibility arguments, clause elements, redirects, and attribution to an exact occurrence cwd. Unknown joins clear those resolutions and retain the <dynamic-cwd> marker. Explicit redirect targets also become Unknown unless provenance proves that the target is cwd-independent; an unreachable body never borrows the parse-time cwd. Decoded child hosts retain inherited invocation-cwd attribution on their compatibility leaves while isolating child exit state. A redirect authored outside the decoded wrapper payload is evaluated in the invocation scope before child launch, so its target may use a bounded parent-loop binding; redirects authored inside the decoded payload continue to use child scope. When decoded child hosts are nested, an outer redirect retains the outermost invocation scope that authored it rather than binding to the nearest decoded child scope.

Stable v0.3 continues to defer while, if, elseif, else, do, switch, functions, definitions, class/type bodies, and arbitrary execution-bearing expressions outside the bounded forms above.


5. Tokenization Rules

The PwshLexer produces tokens consumed by PwshCommandParser. Token kinds (PwshTokenKind):

  • Word — a bare token: command name, native arg, path, number, $var, ${name}, $env:PATH, drive-qualified C:\x. Backtick escapes are processed; simple $x / ${x} is absorbed into the Word.
  • Parameter — a -Name parameter token. A -Name:value colon form keeps the value; the parser splits on the first : for cmdlet-style commands. Parameter names may contain internal hyphens and ?, so -Name-Part, -?, and native --work-tree each remain one token. An unquoted native --flag=value likewise remains one source token; the native-command parser splits it into flag and value args using the bash rules. = is not cmdlet parameter binding — -Name=value stays one parameter token for a cmdlet.
  • QuotedString — single-quoted, double-quoted, or here-string. Delimiters stripped from the value. Carries IsSingleQuoted and IsHereString flags.
  • Operator;, &&, ||, |, &, (, ), and the redirect operators.
  • Whitespace — spaces/tabs, or a newline run. A newline-bearing run carries IsStatementSeparator = true (the mechanism added in v0.1.5; SPEC.md §5).
  • Continuation — backtick + newline. Treated as whitespace.
  • Comment# line comment to end-of-line, or <# ... #> block comment. Dropped by the significant-token filter.
  • ScriptBlock — a balanced { ... } region, emitted whole. Command arguments retain a DynamicSkip compatibility arg. Structural binding may additionally recurse into it as a direct, cataloged, or conservative unknown execution region; proved data remains opaque.
  • Subexpression — a balanced $( ... ), @( ... ), or @{ ... } region, emitted whole. The v0.3 structural parser recursively lowers supported $() regions and retains the outer compatibility arg as DynamicSkip; unsupported execution-bearing array/hash expressions fail closed.
  • Splat@identifier (splatting). Parser → DynamicSkip arg.
  • StopParsing — the --% token; the pipeline-element remainder is opaque.
  • UnparseableSentinel — an unbalanced region or a lex-time-detected unsupported construct. Carries the reason; the parser lifts it to ParsedCommand.IsUnparseable (§11).

Quote handling

  • Single quotes '...' preserve bytes literally — no escape processing, no expansion. A doubled '' inside is an escaped single quote.
  • Double quotes "..." allow backtick escape sequences and recognize $var / ${name} / $env:X / $( ... ) interpolation — but the parser does not expand; $var stays literal in the token value and the resolver (§8) classifies it. A $( ... ) inside a double-quoted string does not split the compatibility token, but v0.3 recursively exposes its commands through SimpleCommandSyntax.Substitutions. Backtick character escapes are decoded into the same logical value PowerShell passes to a command, including `u{hex} Unicode scalar escapes.
  • Here-strings@" + newline ... newline + "@ (expandable) and @' + newline ... newline + '@ (literal). The closing delimiter must start a line. Lexes to one QuotedString token with IsHereString=true. Expandable here-strings decode backtick character escapes and v0.3 discovers every executable $() interpolation; literal here-strings preserve their body bytes and never create substitution commands.
  • Unbalanced quotes or here-strings → IsUnparseable with a reason.

Escape handling

  • The backtick ` is PowerShell's escape character — not a command-substitution delimiter. There is no backtick command substitution in PowerShell. Outside quotes, a backtick escape contributes its decoded character to the current Word. Inside expandable strings, `n, `t, `", `$, an escaped backtick, and `u{hex} are recognized; `e decodes to ESC. Unicode escapes accept one to six hex digits up to 0x10FFFF, including UTF-16 surrogate code units as PowerShell does. A malformed Unicode escape emits an unparseable sentinel. Decoding occurs before static command-string recursion so an escaped newline cannot hide an additional command. Decoded PowerShell whitespace, including vertical tab, form feed, and Unicode separator characters, becomes a token boundary; only CR/LF separate statements. A decoded NUL marks the command unparseable rather than being merged into a verb or argument.
  • Backtick + newline is a line continuation.

Operator boundaries

Operators terminate the current token without surrounding whitespace — gci|rm lexes as [gci, |, rm], exactly like bash (SPEC.md §5).

Comment handling

  • # at a token boundary starts a line comment to (but not including) the next newline — the same boundary rules as bash (SPEC.md §5 "Comment handling"). # in the interior of an unquoted word (abc#def) is a literal character.
  • <# ... #> is a block comment. An unterminated block comment → UnparseableSentinel.
  • Comment tokens are dropped by the significant-token filter. Comment-only input parses to Clauses = [], IsUnparseable = false.

Redirect tokenization

Recognized redirect operators, longest-match first: >, >>; N> and N>> for stream N in {1,2,3,4,5,6}; *> and *>> (all streams); and the stream-merge forms N>&1 for source stream N in {2,3,4,5,6} and *>&1. PowerShell reserves < for future use, success stream 1 cannot be a merge source, and merge targets other than success stream 1 are syntax errors. A redirect target of $null or ${null} is recognized as the discard sink. §8 covers how stream numbers map onto the RedirectDirection enum.

One command may redirect each source at most once. The unnumbered output source and explicit stream 1 are the same source; a merge and a file redirect also conflict when they consume the same numbered source. * remains its own source and may coexist with a numbered redirect, matching native PowerShell.


6. Verb / Cmdlet Tables

PowerShell command names come in two shapes; the parser recognizes both.

6.1 Cmdlet recognition

A token is cmdlet-shaped when, case-insensitively: Kind == Word; length in [3, 64]; it contains exactly one -; the segment before - is an approved PowerShell verb (the closed set returned by Get-VerbGet, Set, New, Remove, Add, Clear, Invoke, Test, Start, Stop, Import, Export, Select, Out, etc., held in the static PwshApprovedVerbs table); and the segment after - begins with an ASCII letter and is ASCII letters/digits only. Get-ChildItem, get-childitem, and GET-CHILDITEM all match.

Gating on the approved-verb table — rather than "any letters before the dash" — is deliberate. Hyphenated native commands (docker-compose, apt-get, git-lfs, dotnet-counters) are not cmdlets, and their first segment (docker, apt, git, dotnet) is not an approved verb; they therefore fall through to the native-command path (§6.2) and keep their multi-token subcommand chains. A pure-shape rule would misclassify them as one-token cmdlets, truncating the chain and dropping their args from FileVerb path classification. A hyphenated native tool whose prefix happens to be an approved verb is a rare, accepted false positive.

A cmdlet-shaped first token (or one resolved through the alias table, §6.3) makes the verb chain exactly one token. PowerShell cmdlets take explicit parameters; there is no git push origin style nested-subcommand idiom for cmdlets, so the bash greedy walk does not apply to them.

6.2 Native-command verb chains

When the first token is neither cmdlet-shaped nor a known alias (git, dotnet, npm, kubectl, python, ...) it is a native command. Native commands reuse the bash greedy verb-chain walk (SPEC.md §6.1). The parser appends the first token and then walks consecutive verb-like Word tokens. The walk transparently consumes flag-with-value pairs. It stops at a path-shaped token, non-verb-like token, flag, operator, quoted string, or opaque token.

The path-shape test uses BashResolver.LooksLikePath. Both native parsers therefore share one boundary. The verb-like predicate is the bash predicate unchanged (SPEC.md §6.1: Kind == Word, length [1, 64], first char ASCII lowercase [a-z], remaining chars [a-z0-9._-]).

The path-shape boundary does not apply to the first native command token. For example, deploy.sh status has verb tokens deploy.sh and status.

Keeping the predicate case-sensitive — not relaxing it to accept an uppercase first char — is deliberate. The leading-lowercase rule is the only signal that stops the greedy walk at a capitalized identifier (dotnet ef migrations add InitialCreate stops at InitialCreate); relaxing it would absorb InitialCreate into the verb chain and make the PowerShell verb chain diverge from the bash parser's for the identical command. Real native subcommands are lowercase in the wild (dotnet ef migrations add), so case-sensitivity costs nothing. Cmdlet names, aliases, and parameter names are still matched case-insensitively against their tables (§4); only the native greedy-walk predicate stays case-sensitive. Clause.Verb remains a convenience hint, not a security contract (SPEC.md §6.1.1); consumers pattern-prefix match.

6.3 Built-in alias table

PwshAliases owns static, case-insensitive maps from a typed alias to its canonical cmdlet for each supported dialect. When the first token is a known alias in the selected dialect the parser:

  • keeps the verbatim typed token in VerbChain.Tokens (source fidelity; pattern-matching sees what was typed), and
  • sets VerbChain.CanonicalVerb to the canonical cmdlet, which drives the per-cmdlet path rules (§7) and the Cwd/File verb classification (§6.4).

Alias resolution is unconditional within the selected dialect — it is the single most security-relevant normalization the PowerShell parser performs, and the v0.1 doctrine ("consumers can relax, they can't un-execute") means it is not a knob. VerbChain.Tokens already preserves the verbatim token, so resolution costs no source fidelity; a switch to disable it would only weaken alias-keyed gate rules.

PwshAliases MUST contain the complete default alias set of each supported dialect — not a hand-picked subset. An alias absent from the table degrades to a native command (§6.2); a file cmdlet so degraded silently loses its per-verb path classification (§7) — a false-negative-shaped failure in a security parser. The full set is finite and enumerable (Get-Alias), so the §13 oracle gate diffs each dialect table against live output from its matching executable and fails on any missing alias or mismatched canonical definition. A dialect table MUST NOT borrow an alias that exists only in another supported edition. Platform- or SKU-specific aliases may remain in the static table when the selected dialect can define them, because a CI host may not expose every optional Windows component. The table below is the security-relevant common excerpt (file, cwd, and code-execution verbs), not the whole table:

Alias(es) Canonical cmdlet
rm, del, erase, rd, rmdir, ri Remove-Item
ls, dir, gci Get-ChildItem
cd, chdir, sl Set-Location
pushd / popd Push-Location / Pop-Location
cat, gc, type Get-Content
cp, copy, cpi Copy-Item
mv, move, mi Move-Item
ni, mkdir, md New-Item
ren, rni Rename-Item
ac Add-Content
sls Select-String
ipcsv / epcsv Import-Csv / Export-Csv
gp / sp / rp Get-ItemProperty / Set-ItemProperty / Remove-ItemProperty
clc / cli Clear-Content / Clear-Item
rvpa Resolve-Path
gi / ii Get-Item / Invoke-Item
% / ? ForEach-Object / Where-Object
iex Invoke-Expression
echo, write Write-Output
pwd, gl Get-Location
select / sort / measure / group Select-Object / Sort-Object / Measure-Object / Group-Object
kill / ps / sleep Stop-Process / Get-Process / Start-Sleep

Keyword / alias collisions. Resolution order:

  1. A token at statement position that exactly equals a control-flow keyword and is immediately followed by ( is the keyword → IsUnparseable. (foreach ($x in $y) is a loop; in gci | foreach { ... } the foreach follows | and precedes {, so it is the ForEach-Object alias.)
  2. Otherwise the selected dialect's alias table wins for known aliases.
  3. In PowerShell 7, curl, wget, sc, set, start, and where remain native commands. Windows PowerShell 5.1 instead applies its default aliases, including curl / wgetInvoke-WebRequest, scSet-Content, setSet-Variable, startStart-Process, and whereWhere-Object.

Get-Error and its gerr alias are PowerShell 7-only and MUST NOT be resolved in the WindowsPowerShell51 dialect. Conversely, Windows PowerShell 5.1-only aliases such as CFSConvertFrom-String, gwmiGet-WmiObject, asnpAdd-PSSnapIn, and trcmTrace-Command remain edition-specific. md and man are normalized to the effective cmdlet reached through their default helper functions (New-Item and Get-Help) while the authored alias token remains unchanged.

6.4 Cwd / File / control-flow tables

PwshVerbs mirrors BashVerbs, keyed by canonical cmdlet plus raw aliases (case-insensitive):

  • CwdVerbsSet-Location, Push-Location, Pop-Location, plus raw cd, chdir, sl, pushd, popd. (SPEC.md §6.2 already pre-lists set-location / push-location.)
  • FileVerbs — the file cmdlets: Get-ChildItem, Get-Content, Set-Content, Add-Content, Remove-Item, Copy-Item, Move-Item, Rename-Item, New-Item, Get-Item, Invoke-Item, Test-Path, Resolve-Path, Out-File, Import-Csv, Export-Csv, Get-FileHash, Compress-Archive, Expand-Archive, Select-String — plus the Windows native file utilities reserved in SPEC.md §6.4 (type, copy, move, del, xcopy, robocopy, findstr).
  • ControlFlowKeywordsif, elseif, else, switch, foreach, for, while, do, until, function, filter, workflow, configuration, class, enum, param, begin, process, end, dynamicparam, trap, data, try, catch, finally, return, throw, break, continue, exit, using, hidden.

6.5 Parameter Binding Model

PowerShell interleaves switch parameters, value-bearing parameters, and positional values in a clause's arg list. Whether a -Name token consumes the next space-separated token as its value depends, in real PowerShell, on the cmdlet's compiled parameter metadata ([switch] vs. a typed parameter). The parser has no metadata; it MUST decide binding from static tables. §7's path classification, §9's Set-Location target rule, and §10's pwsh -Command detection all depend on this model — it is not optional. Without it the parser cannot even compute token boundaries: Get-ChildItem -Recurse C:\logs is "switch -Recurse, positional path C:\logs", but Get-ChildItem -Depth 3 C:\logs is "value-binding -Depth with value 3, positional path C:\logs" — and only a table tells them apart.

6.5.1 Token roles and positional index

After the verb chain, each token has exactly one role:

  • Switch — a -Name parameter consuming no following token.
  • Value-binding parameter — a -Name parameter consuming exactly the next significant token as its value.
  • Positional value — any non-parameter token.

A positional value's positional index is its running count among positional values only. Switch tokens, value-binding-parameter tokens, and the values those parameters consume do not advance the index. So in Copy-Item -Force a -Verbose b, a is positional 0 and b is positional 1.

The colon form -Name:value (§5) always binds — -Name is value-binding, value its value — regardless of the tables below.

PowerShell accepts U+2013 EN DASH, U+2014 EM DASH, and U+2015 HORIZONTAL BAR in place of the leading ASCII parameter dash. Stable v0.3 deliberately fails those forms atomically. The retained v0.2 Arg.IsFlag member derives from an ASCII - in verbatim Arg.Raw; treating an alternate dash as a positional literal is unsafe, while normalizing Raw would violate source provenance. Support therefore requires a later additive representation that can preserve both facts.

6.5.2 The binding tables

Two case-insensitive static tables drive the decision, keyed by (canonicalVerb, parameterName) with a verb-agnostic fallback — the same keying §7.1 uses for the path-parameter table:

  • PwshValueParameters — parameters that consume the next token. Seeded with: the value-bearing common parameters (-ErrorAction, -WarningAction, -InformationAction, -ProgressAction, -ErrorVariable, -WarningVariable, -InformationVariable, -OutVariable, -OutBuffer, -PipelineVariable); every parameter in the §7.1 path-parameter table (-Path, -LiteralPath, -PSPath, -FilePath, -OutFile, -InFile, -Destination, -Source, -Filter, -Include, -Exclude, -Value, -ItemType); and -Name, -Encoding, -Depth, -Stream, -Delimiter, -Command, -EncodedCommand, -File, -ArgumentList.
  • PwshSwitchParameters — parameters known to consume nothing: the switch common parameters (-Verbose, -Debug, -WhatIf, -Confirm) plus frequent cmdlet switches (-Recurse, -Force, -Append, -NoNewline, -PassThru, -Wait, -Quiet, -CaseSensitive, -SimpleMatch, -NoClobber, -AsByteStream, -Hidden, -Directory).

PowerShell parameter-name prefix matching applies: a -Name token matches a table entry when it case-insensitively prefixes exactly one entry (-Rec-Recurse). A prefix matching two or more entries is ambiguous and treated as unknown.

6.5.3 The binding decision

For each -Name token, in order:

  1. Colon form -Name:value → value-binding; value is the colon tail. When the name half carries an = the colon tail is DynamicSkip instead: PowerShell reads -Path=C:\Windows as parameter -Path=C: plus argument \Windows, a name no cmdlet can bind, so the value's role is unknowable and the parser must not classify it.
  2. -Name (prefix-)matches PwshValueParameters → value-binding; consume the next significant token as its value. If there is no next token, or the next token is itself a parameter or an operator, -Name bound nothing and is recorded as a switch.
  3. -Name (prefix-)matches PwshSwitchParameters → switch.
  4. Unknown -Nameswitch (consume nothing).

Rule 4's default — unknown means switch — is the security-conservative choice. If an unknown -Name is actually value-binding, treating it as a switch leaves its value as a positional, where §7's positional rules can still catch a real path (IsPath=true). The opposite default (assume value-binding) would consume that token and hide a real path from the gate — and a missed path is the unrecoverable failure (SPEC.md §1). Over-classifying a stray literal as a positional path is at worst a recoverable extra prompt. This is why §7's positional rules are the floor, not the parameter layer.

6.5.4 The -File collision

-File is a Get-ChildItem switch but pwsh -File is value-binding. Because the tables are keyed by (canonicalVerb, parameterName), -File resolves to value-binding when the canonical verb is pwsh / powershell (§10) and to a switch otherwise; the verb-agnostic entries are the fallback only when no (verb, name) row exists.


7. Per-Verb / Per-Parameter Path-Arg Extraction Rules

PowerShell classifies path arguments in two layers — a parameter-value layer (dominant, because PowerShell names paths explicitly) and a positional layer. Per-cmdlet rules are keyed by the canonical verb (§6.3), so rm x and Remove-Item x classify identically.

Both layers operate on the token roles the §6.5 binding model has already assigned: §6.5 decides parameter consumption and positional index; §7 decides path-ness. A §7 rule never re-derives whether a -Name consumed the following token — it trusts §6.5.

7.1 Path-typed parameters

A parameter value is a path when the parameter name (case-insensitive) is in the path-parameter set. The unambiguous, verb-agnostic names:

Parameter Value classification
-Path, -LiteralPath, -PSPath path
-FilePath, -OutFile, -InFile path
-Destination, -Source path
-Filter, -Include, -Exclude glob — Kind=Glob if metachars present, else Literal; IsPath=false
-Value (for Set-Content / Add-Content) not a path (file content)
-ItemType (for New-Item) not a path (literal File / Directory)

-Name is context-dependent (a filesystem leaf for New-Item / Rename-Item; not a path for Get-Process / Get-Service). Key the path-parameter table by (canonicalVerb, parameterName) with the verb-agnostic set above as the fallback — the PowerShell analog of bash's FlagValueIsPath table (SPEC.md §7).

7.2 Positional path rules

Most file cmdlets take -Path as positional 0; with no preceding path-parameter, positional 0 (and beyond — positional index per §6.5.1) classifies as a path for canonical FileVerbs. Per-cmdlet overrides:

Canonical cmdlet Positional rule
Get-ChildItem, Get-Content, Get-Item, Remove-Item, Set-Location, Push-Location, Invoke-Item, Test-Path, Out-File, Import-Csv all non-flag positionals are paths
Copy-Item, Move-Item positional 0 = source path, positional 1 = destination path
Rename-Item positional 0 = path, positional 1 = new name (path fragment)
New-Item positional 0 = path
Set-Content, Add-Content positional 0 = path; -Value is content
Select-String positional 0 = pattern, rest = paths (mirrors bash grep)
ForEach-Object, Where-Object positional script blocks remain DynamicSkip compatibility args and additionally bind typed execution regions; no path positionals

The default for a canonical FileVerb with no override is "all non-flag positionals are paths," exactly as SPEC.md §7.

7.3 Native commands

After selected-dialect alias resolution, commands that remain native reuse the bash per-verb rules table verbatim — git, PowerShell 7 curl, tar, etc. behave identically to SPEC.md §7 (curl / wget: the first positional is a URL; curl -o / -D values and Wget -o / -O values are paths, while curl -d data is non-path unless @file requests a file read; @- denotes stdin). Tar -F / --info-script / --new-volume-script values execute commands and therefore safe-fail as DynamicSkip, not paths. This includes hyphenated option names and the bash --flag=value split: the flag and value surface as separate args, and a curated flag's value receives the same path classification in both parsers. Native --flag:value has no cmdlet-binding semantics and remains verbatim. PowerShell still owns outer tokenization: spaced curl operands beginning with @ should be quoted because @name is splatting and bare @- is a parse error. Use forms such as -d "@request.json" / -d "@-", or bind a file inline as --data=@request.json, so the native command receives one value. An equals prefix adjacent to a quoted value, such as --data='@C:\payload file', is also one native argument and one clause element. Windows PowerShell 5.1 curl / wget do not reach these native rules because their dialect aliases bind to Invoke-WebRequest; its cmdlet parameter rules apply instead. Explicit curl.exe remains native.


8. Resolver

The resolution doctrine — single-quote bypass, DynamicSkip for anything not statically knowable, the (ArgKind, Resolved, IsPath) result — is defined in SPEC.md §8 and is unchanged. PwshResolver parallels BashResolver with the PowerShell-specific steps below. Resolution order:

  1. Single-quote / literal here-string bypass. A token from a single-quoted string or an @'...'@ here-string is literal bytes — skip steps 1–7. (SPEC.md §8 step 0.)
  2. ~ expansion. ~, ~\path, ~/pathHomeDirectory. ~user is not supported → DynamicSkip.
  3. Home-variable substitution. $HOME, ${HOME}, $env:USERPROFILE, ${env:USERPROFILE} expand to HomeDirectory. $PSScriptRootDynamicSkip (the script's own directory is not knowable at gate time). Every other $var / $env:NAME / ${name} reference → DynamicSkip in a path slot, ArgKind.EnvVar in a non-path slot. (Mirrors bash: only the home variables are privileged.)
  4. Provider-qualifier stripping. Strip a leading FileSystem:: or Microsoft.PowerShell.Core\FileSystem:: prefix (case-insensitive) and resolve the remainder. This generalizes the lowercase filesystem:: strip the bash resolver already performs (SPEC.md §8).
  5. Drive-qualified paths. A single-ASCII-letter drive (C:\, d:/foo) is a rooted filesystem path; normalize \ to / for output consistency with bash. A non-FileSystem PSDriveHKLM:, HKCU:, Env:, Cert:, Variable:, Function:, Alias:, or any qualifier longer than one letter — is not a filesystem path: classify Kind=Literal, IsPath=false. A registry or certificate "path" must not be treated as a file by a zone gate.
  6. UNC paths. \\server\share\... is a rooted path, normalized to //server/share/... (the bash resolver already performs this collapse).
  7. Glob detection. Wildcard metacharacters *, ?, and [ ]ArgKind.Glob in a path slot (IsPath=true), IsPath=false otherwise. The parser does not expand globs (SPEC.md §8).
  8. Relative-path resolution. A token with no drive qualifier, no leading \\, and no leading / or \ is joined to PwshParserOptions.WorkingDirectory. A dynamic Set-Location target makes subsequent relative paths DynamicSkip (the working-directory- unknown mechanism, SPEC.md §9).

A redirect target of $null or ${null} sets Redirect.IsDynamicSkip = true — it is the discard sink, not a file; do not resolve it. The LooksLikePath heuristic (SPEC.md §8) additionally recognizes a leading FileSystem:: / Microsoft.PowerShell.Core\FileSystem:: qualifier.

Comma-separated arrays. PowerShell's , is the array operator — -Path a.txt,b.txt binds an array of two paths. The lexer keeps a comma-joined run as one Word (, is not an operator, §5). In a path slot, a token with an unquoted top-level , is marked Kind=DynamicSkip, IsPath=false: the v0.2.0 parser neither splits it into per-element path args nor resolves the mangled join (a single bogus joined path would mislead a zone gate). Safe-fail — the consumer prompts on the raw command — is the correct v0.2.0 behavior; per-element path extraction is a candidate v0.2.x item (§18).

Environment-variable prompt rate. PowerShell commands reference path-bearing variables ($env:TEMP, $env:APPDATA, $env:windir, $env:ProgramFiles) far more often than bash commands reference $VAR paths. With only the home variables privileged (step 2), a materially higher share of PowerShell path args resolves to DynamicSkip than in the bash parser. That is the intended safe failure mode — the consumer prompts — but implementers and consumers should expect a higher prompt rate. Privileging more $env: names is a deliberate non-goal: the value at parse time need not match the value when the command runs.

v0.3 resolver provenance and consumer contexts

The ordered v0.2 resolution steps describe compatibility results, but they do not permit v0.3 to reconstruct expansion from decoded text. The PowerShell front end retains each ordered literal, typed-expansion, or opaque fragment, its exact-or-null source span, allowed lexical transforms, expansion identity, cardinality, and opaque cause through decoding. Raw spelling, decoded logical values, and source spans keep their v0.2 meanings.

Resolution receives one explicit consumer context:

  • native argument;
  • cmdlet Path binding;
  • cmdlet LiteralPath binding;
  • redirect target.

Native arguments apply PowerShell lexical quote rules but never acquire cmdlet provider or PSDrive semantics merely because their decoded text looks provider-qualified. A quoted native ~, *.txt, or FileSystem::C:\logs\x remains literal. An unquoted native wildcard remains unknown without filesystem enumeration.

Cmdlet Path and redirect contexts apply tilde, wildcard, FileSystem provider, and PSDrive semantics after value formation even when quoted. LiteralPath suppresses wildcard interpretation but still applies quoted tilde, provider, and PSDrive semantics. An unproved PSDrive mapping or wildcard cardinality remains unknown. A drive-relative value such as C:foo is not an absolute filesystem path proof and fails closed.

Escaped interpolation starts remain exact literals. Recognized special, numeric, scoped, braced, and Unicode-named variables retain typed expansion identity while their value stays unknown without a bounded proof. An unterminated ${...} interpolation makes the entire result unparseable. Adjacent fragments after a redirect operator form one target; the suffix is never emitted as an unrelated argument.

Exact $HOME and $env:USERPROFILE composition requires the corresponding current-runspace or environment fact. A decoded child with uncontrolled profile startup has neither fact even when its parent was analyzed under the isolated mode. Native-versus-cmdlet path interpretation uses the authored command spelling and parser tables; ambient runtime shadowing does not erase that classification. An explicit source-level mutation may invalidate it.

When every fragment, transformation, binding fact, cwd/home fact, and consumer fact is exact, mixed literal and expandable fragments compose to one exact compatibility result rather than becoming DynamicSkip. Any opaque or incompletely mapped region, unknown required fact, or unsupported transform fails closed. The resolver does not recursively rescan produced text.

Redirect stream → RedirectDirection mapping

RedirectDirection (SPEC.md §3) has five members; PowerShell has more output streams than that. The mapping is intentionally lossy — a zone gate cares about the redirect target path, which is preserved exactly, not about which stream produced it:

PowerShell redirect RedirectDirection
>, 1> Out
>>, 1>> Append
2> ErrOut
2>> ErrAppend
3>6>, *> Out (lossy — warning/verbose/debug/information/all)
3>>6>>, *>> Append (lossy)
stream merge N>&1 for N in 26, or *>&1 ErrOut when N is 2, else Out; Target carries &1 verbatim with IsDynamicSkip=true

The table above remains the v0.2 Redirect compatibility mapping. v0.3 also populates the closed RedirectAnalysis family: RedirectSource.PowerShellAllStreams preserves *, RedirectSource.Descriptor preserves numeric streams, and runtime alternatives distinguish FileRedirectAnalysis output/append from static DescriptorDuplicateRedirectAnalysis. Descriptor alternatives are not paths. PowerShell's grammar does not admit descriptor close, move, computed merge targets, or file input redirection; those spellings make the whole parse unparseable. $null and ${null} remain an incomplete explicit redirect until the public operation vocabulary has a discard-sink representation; consumers must continue to fail closed.


9. Set-Location-in-Compound Propagation

PowerShell honors the same cwd-attribution propagation bash applies to cd (SPEC.md §9): Set-Location <dir>; cmd runs cmd with cwd <dir>.

Rules (parallel to SPEC.md §9):

  1. A clause whose canonical verb is Set-Location (raw aliases cd, chdir, sl) sets the attributed cwd for subsequent clauses in the same compound. The cwd target is the value of -Path / -LiteralPath when present (per the §6.5 binding model), else positional 0 (positional index per §6.5.1). Set-Location with no positional and no -Path targets HomeDirectory.
  2. Subsequent clauses receive a synthetic Arg with IsCwdAttribution=true. Its kind tracks the Set-Location target:
    • target resolves to a filesystem path — a literal path, ~, $HOME, $env:USERPROFILE, a drive-qualified or UNC path, or the no-positional home case → Kind=Literal, IsPath=true, Resolved set; subsequent relative paths in the compound resolve against it.
    • target is dynamic (cd $repo), a non-FileSystem PSDrive (cd HKLM:, cd Env: — §8 step 4), or Set-Location - / Set-Location + (previous/next location, not statically knowable) → Kind=DynamicSkip, IsPath=false, Resolved=null; subsequent relative paths in the compound also become Kind=DynamicSkip, IsPath=false — the working directory is no longer statically known (the bash dynamic-cd mechanism, SPEC.md §9).
  3. A later Set-Location replaces the attributed cwd.
  4. Sub-pipeline ( ... ) boundaries do not isolate attribution. PowerShell ( ... ) is a grouping operator, not a subshell — it creates no working-directory scope ($PWD is runspace state, not a scoped variable; §4). A Set-Location inside ( ... ) changes the cwd for everything after the group, and attribution propagates across the group boundary. This is the one place PowerShell Set-Location propagation deliberately diverges from bash cd, where a subshell does isolate (SPEC.md §9 rule 4 / §10). Importing the bash isolation here would under-attribute — (cd C:\sensitive); Remove-Item * would lose the sensitive cwd — and produce a security false-negative.
  5. Attribution is purely additive — the Set-Location clause and every subsequent clause keep everything the user typed, plus the synthetic arg.

Push-Location / Pop-Location (pushd / popd) are CwdVerbs — their first non-flag positional is path-classified — but they do not add a synthetic attribution arg in v0.2.0, mirroring bash locked interpretation #5 (SPEC.md §9). A real directory-stack model is deferred (§18).

Attribution propagates across the statement separators ;, &&, ||, and newline. Within a single pipeline the elements share one cwd; | joins pipeline elements within a statement, not separate statements.


10. Subexpression & Command-String Recursion

Opaque regions

Script blocks { ... }, array subexpressions @( ... ), and hash literals @{ ... } are bounded by the shared OpaqueRegionScanner. PowerShell command subexpressions $( ... ) use a specialized scanner that understands nested subexpressions, quoted regions, backtick escapes, line and block comments, and the shared structural-depth cap. Direct $() bodies recognize line comments only at PowerShell word boundaries. A $() discovered while decoding an expandable string or here-string rejects comment-bearing interiors conservatively because the parent quoting context changes whether PowerShell can close the region. Here-strings nested inside $() remain an unsupported grammar boundary. Each bounded region is emitted as one token. The compatibility parser retains each as one Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }, Raw being the verbatim region slice. Stable v0.3 additionally parses every supported executable $() interior into SimpleCommandSyntax.Substitutions. Execution-bearing @() / @{} forms that cannot be completely discovered make the whole result unparseable. Splatting @var remains DynamicSkip. The --% stop-parsing token makes the pipeline-element remainder one DynamicSkip arg. Script-block arguments additionally pass through the §4 receiver/binding catalog. Thus gci | ? { Test-Path $_ } | rm retains a three-clause compatibility pipeline, and the middle clause owns a Filter execution region whose body commands are projected. A canonical receiver proved to consume a block as data retains only the opaque arg. An unknown receiver retains an unknown incomplete region rather than asking consumers to decide whether hidden commands execute.

PowerShell $() runs in the current runspace scope. A Set-Location inside a subexpression affects later inner commands, the containing command after value evaluation, and following outer commands. Unknown location mutations propagate as unknown. Because Set-Location can fail, an ungated statement sequence also joins the prior location; a success-gated && continuation may use the proved new location. The v0.2 compatibility leaf remains authored evidence, while the v0.3 occurrence analysis is the failure-aware security fact and sanitizes stale exact compatibility attribution. This differs from Bash command substitution, whose state is isolated from the containing shell.

OpaqueRegionScanner is grammar-agnostic but escapes on backslash; the PowerShell script-block, array, and hash paths give it a backtick-escape mode so { `} } scans correctly. The specialized $() scanner applies the same backtick behavior directly.

pwsh -Command recursion

pwsh -Command "<inner>" is the PowerShell analog of bash bash -c (SPEC.md §10). The parser recognizes a clause whose verb is pwsh, powershell, pwsh.exe, or powershell.exe (case-insensitive) carrying a -Command parameter (the canonical -Command, the short -c, or any unambiguous -Comm* prefix).

PowerShell's -Command consumes everything after it on the invocation, not just one quoted token. The parser handles all three real forms:

  • Quoted stringpwsh -Command "Remove-Item C:\tmp\x". Parse the string value as a fresh ParsedCommand.
  • Script blockpwsh -Command { Remove-Item C:\tmp\x }. Parse the script-block interior (braces stripped) as a fresh ParsedCommand.
  • Bare / multi-tokenpwsh -Command Remove-Item C:\tmp\x. Take the verbatim source slice from the first token after -Command through the last command token and parse that as a fresh ParsedCommand.

In every form the inner clauses surface inline, each with IsCommandStringWrapped = true. Not recognizing the bare/multi-token form would let pwsh -Command Remove-Item C:\x leak through as opaque args of pwsh — a verb-keyed gate would never see Remove-Item. The depth-5 recursion cap applies; deeper nesting, or an inner parse that itself yields IsUnparseable = true (e.g. a -Command payload that decodes to a control-flow script), sets the outer ParsedCommand.IsUnparseable = true so the whole command routes to safe-fail (SPEC.md §10).

A terminal redirect belongs to the outer PowerShell invocation, not the child command string. The parser appends each such redirect to the last surfaced 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.

pwsh -EncodedCommand recursion

pwsh -EncodedCommand <base64> (also the unambiguous -e / -enc / -encodedc* prefixes) runs a Base64-encoded, UTF-16LE command string — the prime way to hide a command from a security gate. The parser decodes and recurses: it takes the single token following -EncodedCommand as the payload (the parameter name defines it as base64 — the parser does not heuristically sniff for "base64-shaped" tokens), Base64-decodes it, UTF-16LE-decodes the result, strips a leading UTF-16 BOM (U+FEFF) if present, parses the remainder as a fresh ParsedCommand, and surfaces the inner clauses inline with IsCommandStringWrapped = true, under the same depth-5 cap. Seeing through this obfuscation is core value for a security-gating parser.

The BOM strip is not cosmetic: a U+FEFF left on the front of the decoded string corrupts the first token — the verb — and the verb is the gate key.

On any failure — the token is not well-formed base64, the bytes are not valid UTF-16LE, the decoded payload exceeds the input cap (§11), the inner parse itself yields IsUnparseable = true, or the depth cap is exceeded — the parser sets ParsedCommand.IsUnparseable = true with a reason naming the failure.

Invoke-Expression

Invoke-Expression and its canonical iex alias recurse only when binding produces exactly one scalar string token whose value is statically knowable. Static call-operator spellings such as & 'iex' ... and module-qualified Microsoft.PowerShell.Utility\Invoke-Expression receive the same handling. Accepted payloads are a single-quoted string, a literal here-string, a bare non-dynamic word, or a double-quoted string / expandable here-string whose lexer token records no unescaped variable or subexpression interpolation. An optional exact -Command parameter may bind that one token. The normal colon forms are accepted: -Command:Get-Date carries an inline bare value, while -Command:'Get-Date' and -Command:"Get-Date" bind the following quoted token. Inline values receive the same backtick decoding as ordinary words. A # immediately after an empty colon starts a comment and therefore leaves the required payload missing. Dynamic inline values remain opaque.

For a static payload, the parser consumes the outer expression clause and surfaces the inner clauses inline with IsCommandStringWrapped = true. The first inner clause takes the operator that preceded the outer expression. Surfaced Clause.Elements retain their inner raw and decoded values but have null source spans because their offsets cannot be mapped exactly into the outer ParsedCommand.Source. The parse increments the same depth counter used by pwsh -Command and -EncodedCommand, and the payload passes through the same 64 KiB input cap.

Invoke-Expression executes in the caller's scope rather than a fresh child process. Its inner parse therefore shares the current Set-Location context: relative paths inherit the caller's effective location, and a static inner Set-Location updates attribution for clauses following the expression in the outer command. Child pwsh recursion remains isolated.

The parser never evaluates variables, interpolation, concatenation, subexpressions, script blocks, arrays, or other computed expressions. When a direct computed payload has a source expression, the outer expression clause remains and the entire payload source slice becomes one Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }. Its authored Clause.Elements retain the expression verb, an optional separate -Command parameter, and one source-aligned DynamicSkip payload region. An inline form such as -Command:$code remains one authored parameter element. Pipeline input, missing payloads, and ambiguous parameter binding set ParsedCommand.IsUnparseable = true; an incoming pipeline is dynamic even when an explicit literal argument also appears. These rules prevent a clean, persistently approvable Invoke-Expression clause from hiding runtime code. Because computed code can mutate variables, aliases, functions, modules, and location in the current scope, a direct dynamic payload invalidates every following binding and command-resolution proof and makes location attribution dynamic for every following relative path. The same rule applies to iex, a static call-operator spelling, and the supported module-qualified spelling.

The dot-source invocation operator remains unparseable except for one inline, completely delimited script block. Dot-sourced files/dynamic values and unsupported module-qualified cmdlets are unparseable rather than being exposed under a misleading raw verb. This validation applies independently to every simple command inside structural lists, pipelines, loops, groups, and substitutions, including built-in cmdlets such as Tee-Object whose verb is not in the approved-verb table. The supported module-qualified exceptions are Microsoft.PowerShell.Utility\Invoke-Expression and the version-pinned §4 execution-region receiver catalog under its authored-receiver contract. A quoted string is a command identity only when preceded by the call operator &; otherwise it is an unsupported expression. Any dynamic command identity invalidates following location attribution because it can resolve to current-scope code that calls Set-Location.


11. Parser Anomaly Behavior

The v0.3 safe-fail contract — set IsUnparseable=true, set UnparseableReason, return empty Commands and Clauses, retain at most a diagnostic Syntax tree, and never throw on a well-formed string — is defined in SPEC.md §11.

PwshCommandParser sets ParsedCommand.IsUnparseable = true for:

  1. Lexer sentinels — unbalanced single/double quote, unterminated here-string, unterminated <# ... #> block comment, unbalanced { } / $( ) / @( ) / @{ }, unbalanced grouping ( ).
  2. Unsupported control-flow at statement/verb positionswitch, for, do, until, if, elseif, else, or while, plus any foreach form outside the bounded stable-v0.3 grammar in §4.
  3. Definition keywordsfunction, filter, workflow, configuration, class, enum.
  4. Block / trap / data keywordsparam, begin, process, end, dynamicparam, trap, data, try, catch, finally.
  5. Statement keywords leading a statementreturn, throw, break, continue, exit (including exit 0), using, and hidden. The call and dot-source operators support only one completely delimited inline script block. & { ... } arg and . { ... } arg remain unparseable until block-argument binding is modeled. . ./script.ps1 and a dynamic dot-source target remain unparseable because their executable content is unavailable.
  6. Trailing & background-job operator — a & at the end of a pipeline (not at verb position). & git status is the call operator and parses; git status & is a background job and does not.
  7. Assignment statement — a statement that begins $var = ....
  8. Bare type-literal / .NET method call — a statement that is just [type]::Member(...), which has no verb.
  9. PowerShell command-string recursion failure — the shared Invoke-Expression / pwsh -Command / -EncodedCommand recursion depth exceeds 5, an -EncodedCommand payload fails to decode, an expression payload comes from a pipeline or cannot be bound safely, or an inner parse itself yields IsUnparseable (§10).
  10. Oversized input — the command string, a static Invoke-Expression payload, or a decoded -EncodedCommand payload, exceeds the parser's input cap. The cap guards the per-shell-call hot path against a pathological or malicious input (a multi-megabyte base64 blob would otherwise decode and recurse up to five deep). The cap is a fixed internal constant — 64 KiB of UTF-16 characters, applied to the top-level input and to each decoded payload — not a PwshParserOptions knob: a security limit a consumer can raise is not a limit. Over-cap input sets IsUnparseable = true with a reason naming the cap. (This guard is introduced by the PowerShell parser; the bash parser may adopt the same cap in a later v0.1.x.)

Diagnostic precedence (most-informative first, mirroring SPEC.md §11): (1) input over the size cap, checked before lexing; (2) lexer UnparseableSentinel tokens; (3) a control-flow / definition / block keyword at statement position; (4) a trailing & background job; (5) an assignment or bare type-literal statement; (6) grouping ( ) balance errors or an unexpected operator; (7) a command-string binding failure or recursion cap, an inner-parse IsUnparseable, or an -EncodedCommand decode failure.

Consumers route an unparseable command to safe-fail exactly as for bash (SPEC.md §11, Appendix A).


12. Public Examples

Hand-authored input / expected-AST pairs anchoring understanding. Each shows the salient AST fields; omitted fields take their documented defaults.

Simple cmdlet with a path parameter

Input: Get-ChildItem -Path C:\logs -Recurse

Clause 0: Operator=None, Verb=[Get-ChildItem]
          Args=[ {Raw="-Path", IsFlag=true},
                 {Raw="C:\logs", Kind=Literal, IsPath=true, Resolved="C:/logs"},
                 {Raw="-Recurse", IsFlag=true} ]

Alias resolution

Input: gci C:\logs

Clause 0: Operator=None, Verb=[gci], CanonicalVerb="Get-ChildItem"
          Args=[ {Raw="C:\logs", Kind=Literal, IsPath=true, Resolved="C:/logs"} ]

Tokens keeps the verbatim gci; CanonicalVerb carries Get-ChildItem.

Pipeline with an opaque script block

Input: gci | ? { $_.Length -gt 1mb } | rm

Clause 0: Operator=None, Verb=[gci], CanonicalVerb="Get-ChildItem"
Clause 1: Operator=Pipe, Verb=[?],   CanonicalVerb="Where-Object",
          Args=[ {Raw="{ $_.Length -gt 1mb }", Kind=DynamicSkip, IsPath=false} ]
Clause 2: Operator=Pipe, Verb=[rm],  CanonicalVerb="Remove-Item"

Set-Location propagation

Input: cd C:\repo; git status

Clause 0: Operator=None, Verb=[cd], CanonicalVerb="Set-Location",
          Args=[ {Raw="C:\repo", IsPath=true, Resolved="C:/repo"} ]
Clause 1: Operator=Sequence, Verb=[git, status],
          Args=[ {Raw="C:\repo", IsPath=true, Resolved="C:/repo",
                  IsCwdAttribution=true} ]

Recursing into pwsh -Command

Input: pwsh -Command "Remove-Item C:\tmp\x"

Clause 0: Operator=None, Verb=[Remove-Item], IsCommandStringWrapped=true
          Args=[ {Raw="C:\tmp\x", IsPath=true, Resolved="C:/tmp/x"} ]

Recursing into pwsh -EncodedCommand

Input: pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA==

(payload decodes to `Get-Date`)
Clause 0: Operator=None, Verb=[Get-Date], IsCommandStringWrapped=true

Registry-provider path is not a filesystem path

Input: Remove-Item HKLM:\Software\X

Clause 0: Operator=None, Verb=[Remove-Item],
          Args=[ {Raw="HKLM:\Software\X", Kind=Literal, IsPath=false} ]

Unparseable — an unsupported control-flow construct

Input: switch -Wildcard ($value) { '*.txt' { Remove-Item $_ } }

IsUnparseable=true
UnparseableReason="PowerShell switch is unsupported in v0.3"
Commands=[]
Clauses=[]

13. Test Corpus Contract

The corpus is the acceptance contract for the parser, exactly as in SPEC.md §13. The JSON entry schema, the file-name convention (NN_descriptive_slug.json), and the [Theory]-driven runner are unchanged. PowerShell-specific deltas:

Location

PowerShell corpus entries live in tests/ShellSyntaxTree.Tests/Corpus/powershell/*.json. Corpus files are directory-routed by shell: an entry under Corpus/bash/ is parsed with BashParser; an entry under Corpus/powershell/ is parsed with PwshParser using its optional dialect field. Omitting that field preserves PowerShell 7. The corpus runner and the PII audit are refactored to enumerate every Corpus/<shell>/ directory rather than a hard-coded bash path.

Schema additions

The shared corpus DTO gains three optional fields:

  • canonicalVerb (per clause) — the expected VerbChain.CanonicalVerb. Omit to assert null (every bash entry, and PowerShell canonical/unknown verbs); provide the canonical cmdlet to assert an alias was resolved.
  • oracleExpectation (per entry, meaningful only when isUnparseable: true) — SyntaxError (genuinely malformed PowerShell — the selected real shell must also reject it) or OutOfScope (valid PowerShell the parser deliberately does not model — the selected real shell must accept it). Defaults to SyntaxError. OutOfScope also covers an input that is valid PowerShell but that the parser declines for a non-grammar reason — an -EncodedCommand decode failure, dynamic pipeline-fed Invoke-Expression, an over-cap input (§11), or a recursion-depth overflow — because the selected real shell parses the outer invocation without error. The same rule applies when ShellSyntaxTree decodes a static child-host or Invoke-Expression payload and then rejects syntax inside it: the oracle sees only the authored outer command, where that payload is still data, so the entry is OutOfScope rather than SyntaxError.
  • powerShellDialect (per entry) — selects PowerShell7 or WindowsPowerShell51 for both PwshParserOptions.Dialect and the live oracle. Omit to preserve the PowerShell 7 behavior of every existing entry. Unknown and unrecognized enum values are not silently defaulted.

The shared v0.3 syntax and commands expectations defined in SPEC.md §13 apply unchanged. Selected PowerShell entries SHALL pin current-scope groups, pipeline/list precedence, exact direct spans, null decoded-wrapper spans, command roles, ancestry, completeness, and exact compatibility-leaf identity. The manifest marks these entries explicitly so regeneration preserves the structural assertions; entries without the marker retain the legacy schema.

Coverage targets for v0.2.0

Category Min
Simple cmdlet 10
Alias resolution (assert raw Tokens + CanonicalVerb) 15
Native command / multi-token chain 10
Pipeline 15
Compound / statement separator (;, &&, `
Set-Location propagation 10
Quote handling (single, double, here-string, backtick escape) 10
Parameter binding — named, positional, switch vs. value-binding (§6.5), colon-form, splat 25
Redirect (including streams 1–6 / * and 2>&1) 10
Command-string recursion — pwsh -Command, -EncodedCommand, and static/dynamic Invoke-Expression payloads, including nesting and location scope 25
Dynamic skip ($var, $( ), glob, script block, dynamic verb & $exe) 10
Per-verb / per-parameter path rules 10
Unparseable (control flow, definitions, param(), blocks, assignment, type-literal, trailing &, recursion overflow, over-cap input) 20

Total minimum: 170 entries. Alias-resolution and parameter-binding are PowerShell-specific net-new categories; parameter binding (§6.5) is the hardest part of the parser and is budgeted accordingly. Strive for 200+ once seeded from sanitized real-world commands.

The dialect-matched PowerShell validation gate

A CI test feeds every PowerShell corpus input to the matching real PowerShell parser ([System.Management.Automation.Language.Parser]::ParseInput) via a batched child process: pwsh for PowerShell7 and powershell.exe for WindowsPowerShell51. It enforces:

isUnparseable oracleExpectation selected real shell must report
false (n/a) zero parse errors — the input is valid PowerShell
true SyntaxError at least one parse error
true OutOfScope zero parse errors — valid PowerShell we decline to model

This validates corpus inputs against dialect-matched ground truth; it is not a differential comparison of our AST against PowerShell's AST — a hand-authored expected AST with a wrong parameter binding (§6.5) still passes the gate. Author binding-category entries with extra care, and cross-check them with tools/PwshCorpusTool, which prints, for a given command, the parser's expected JSON block beside the selected-shell verdict; the tool is registered in TOOLING.md. A developer without an oracle executable sees only that dialect's gate skipped. CI requires pwsh on both platforms and requires Windows PowerShell 5.1 on Windows, where the 5.1 corpus is validated.

The same dialect-matched gate compares every live Get-Alias name and definition with the parser table. It fails for a missing alias or a different canonical command. Static platform/SKU supersets are permitted, but a dialect must not inherit an alias known to belong only to another supported edition.


14. Sanitization Process

The sanitization workflow, the PII rule table, and the audit gate are defined in SPEC.md §14 and apply to the PowerShell corpus unchanged — the audit scans every Corpus/<shell>/ directory. PowerShell corpus seeded from real logs carries Windows-shaped paths, so the §14 rule table and the PII-audit regex set gain:

Pattern Replacement
C:\Users\<username>\ (and the mixed-slash C:\Users\<username>/) C:\Users\user\
UNC \\<hostname>\share\ \\internal-host.example\share\
Windows domain DOMAIN\username DOMAIN\user

A literal $env:USERNAME / $env:USERPROFILE reference is not PII (the variable is not a value) and is left as-is; only an expanded concrete user path is sanitized.


15. CI & Release Flow

The CI workflows and the bare-SemVer tag convention are defined in SPEC.md §15 and are unchanged. PowerShell-specific deltas:

  • Versioning. Directory.Build.props VersionPrefix0.2.0. Per SPEC.md §15, v0.2.0 is the first PowerShell parser implementation. Ship a 0.2.0-alpha0.2.0-beta prerelease so Netclaw validates the PowerShell parser and the breaking Clause rename before promotion to stable 0.2.0 — the same beta-then-promote flow used for v0.1.5.
  • Test job. dotnet test runs the bash corpus, the PowerShell corpus, the pwsh validation gate, and the multi-shell PII audit.
  • pwsh availability. CI installs pwsh (already used for the copyright-header script); an explicit step verifies it so the validation gate never silently skips on CI.
  • Release notes. The RELEASE_NOTES.md v0.2.0 section must list the breaking Clause.IsBashCWrappedIsCommandStringWrapped rename with the old→new mapping, and the new PwshParser / PwshParserOptions / ShellParserOptions / VerbChain.CanonicalVerb surface.

16. Implementation Sequencing

A natural order for the implementer. Each numbered item is a self-contained, testable step; most are a single PR.

  1. Public-API surface change (lock first). Add ShellParserOptions, reparent BashParserOptions, add PwshParserOptions (empty record), add a PwshParser skeleton (Parse throws NotImplementedException), add VerbChain.CanonicalVerb and VerbChain.IsDynamic, rename Clause.IsBashCWrappedIsCommandStringWrapped. Update PublicApiSnapshotTests, the corpus DTOs, and the isBashCWrappedisCommandStringWrapped key in the existing bash corpus JSON. All existing bash tests stay green.
  2. PowerShell verb & binding tablesInternal/Pwsh/Verbs/: PwshApprovedVerbs (the Get-Verb set, §6.1), PwshAliases (the complete default alias set, §6.3), PwshVerbs (Cwd / File / control-flow, §6.4), PwshValueParameters / PwshSwitchParameters (the binding tables, §6.5), PwshPerVerbRules (§7).
  3. PwshLexer (Internal/Pwsh/Lexing/) — quoting, backtick escape, $var / $env: / ${name}, parameters, stream redirects, statement separators, comments; script-block/array/hash regions via the shared OpaqueRegionScanner (with the backtick-escape mode), and $() via its specialized scanner. Heavy unit tests.
  4. PwshCommandParser core — pipeline / statement splitting, verb-chain extraction, the §6.5 parameter-binding decision, args & parameters, redirects, VerbChain.IsDynamic for dynamic command names.
  5. Alias resolution — populate VerbChain.CanonicalVerb unconditionally (§6.3); add the [Fact] diffing PwshAliases against live Get-Alias.
  6. PwshResolver — §8.
  7. Per-verb / per-parameter path rules — §7.
  8. Set-Location-in-compound propagation — §9.
  9. PowerShell command-string recursionpwsh -Command, -EncodedCommand, and Invoke-Expression (§10).
  10. Anomaly safe-fail — §11.
  11. Multi-shell refactor of the corpus runner and PII audit — §13 — so both enumerate every Corpus/<shell>/ directory. This MUST precede corpus authoring: the runner cannot execute a Corpus/powershell/ entry while it is hard-coded to the bash path.
  12. Hand-author the PowerShell corpus — §13 (≥170 entries).
  13. pwsh validation gate + tools/PwshCorpusTool — §13; register the tool in TOOLING.md.
  14. SPEC.md edits — update §1 / §2 / §3 / §6.4 / §15 to reflect the shipped v0.2.0 surface, and wire CI; tag 0.2.0-alpha when green.

Most new code lives in PowerShell-only files under Internal/Pwsh/, but some shared surface is touched and must be treated as shared — not as "bash internals":

  • Internal/Lexing/OpaqueRegionScanner gains a backtick-escape mode (§10) — a shared file the bash lexer also uses.
  • The native-command greedy walk (§6.2) and the per-verb path rules (§7.3) are reused from the bash implementation. Reuse means a bash-side change can regress PowerShell. Promote the reused pieces into Internal/Shared/ (or reference them deliberately) and ensure the PowerShell corpus exercises every shared path, so a bash PR cannot silently break PowerShell without a red test.

Extracting a full shared lexer/parser core is still deferred past v0.2.0 (§18) — but the incidental shared surface above is real, and "no bash internals refactored" is not an accurate description of the work.


17. Acceptance Criteria

v0.2.0 ships when all of these hold:

  1. The public API matches §2 — PwshParser, PwshParserOptions, ShellParserOptions, VerbChain.CanonicalVerb, VerbChain.IsDynamic, and the Clause rename. dotnet pack produces ShellSyntaxTree.0.2.0.nupkg.
  2. Every existing bash corpus entry still parses to its expected AST — v0.2.0 is a non-regression for bash.
  3. Every PowerShell corpus entry parses to its expected AST.
  4. The PowerShell corpus has ≥170 entries spanning the §13 categories, including alias-resolution entries that assert both Tokens and CanonicalVerb, and parameter-binding entries that pin switch-vs-value decisions (§6.5).
  5. The pwsh validation gate passes — every PowerShell entry is consistent with real pwsh per the §13 matrix — and the PwshAliases completeness [Fact] (§6.3) confirms the table matches live Get-Alias output.
  6. The PII audit scans both Corpus/bash/ and Corpus/powershell/ and finds zero hits.
  7. dotnet test runs on PR via GitHub Actions and passes on Linux and Windows.
  8. Tagging 0.2.0-alpha triggers publish_nuget.yml and the package appears on nuget.org.
  9. Netclaw consumes the v0.2.0 package: IShellParser resolves, the Clause rename is absorbed, and at least one Netclaw integration test exercises a real PowerShell corpus entry through the live matcher and gets the expected gate decision.

18. Out of Scope

  • PowerShell control flow outside stable v0.3's bounded foreach subset, including while, if, elseif, else, do, and switch.
  • function/filter/class/enum definitions, param()/begin/process/end blocks, trap, and DATA.
  • .ps1 script-file parsing.
  • General PowerShell expression evaluation, $_ / $PSItem semantics, .NET method calls, object-to-string prediction, and runtime pipeline evaluation.
  • Desired State Configuration (DSC).
  • A real Push-Location / Pop-Location directory-stack model (§9).
  • Per-element path extraction from a comma-separated array (-Path a,b,c) — v0.2.0 marks the whole token DynamicSkip (§8); splitting arbitrary parameter arrays remains independently gated.
  • A shared lexer, structural parser base class, or false shared expression grammar. Only post-parse machinery proven identical in both shells is extracted through explicit adapters.
  • Windows cmd parsing — still deferred (SPEC.md §18).

Appendix A: Consumer Contract

The consumer contract is defined in SPEC.md Appendix A and is shell-neutral. Security consumers enumerate every CommandOccurrence and evaluate every explicit redirect. PowerShell adds these identity rules:

  • When gating on verb identity, use the gate key CanonicalVerb ?? (Tokens.Count > 0 ? Tokens[0] : null) — the index is guarded because a redirect-only clause has an empty Tokens (SPEC.md §3). An aliased PowerShell verb (rm, gci) then gates as its canonical cmdlet (Remove-Item, Get-ChildItem).
  • A clause with VerbChain.IsDynamic = true (§3) has no statically-knowable verb identity — the command name is $var or a subexpression. Route it to safe-fail regardless of the gate key; no verb-pattern grant should match it.

Appendix B: Why a hand-rolled PowerShell parser?

SPEC.md Appendix B explains why ShellSyntaxTree hand-rolls its bash parser rather than binding a native one. The same reasoning holds for PowerShell, and one PowerShell-specific option is explicitly rejected:

PowerShell ships a full parser in System.Management.Automation ([System.Management.Automation.Language.Parser]). Binding it is tempting, but it pulls the entire PowerShell SDK as a dependency — large, far from AOT-trim-friendly, and contrary to the "single managed package, zero native deps" constraint (SPEC.md §1). It also produces a full script AST: it would happily parse function, class, and control flow that this library deliberately marks IsUnparseable so consumers route to safe-fail.

The hand-rolled, Pipeline-aware parser keeps the v0.2.0 scope deliberate and the package dependency-free. The real PowerShell parser still earns its keep — as the test-time pwsh validation oracle (§13) that confirms every corpus input is genuine PowerShell — without becoming a runtime dependency.