From df61fdb3a038f284dcb424df82c74905c1abf444 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:26:06 +0000 Subject: [PATCH 01/10] Add host-selected PowerShell dialect analysis --- .github/workflows/pr_validation.yml | 26 ++- IMPLEMENTATION_PLAN.md | 35 ++- PROJECT_CONTEXT.md | 12 +- README.md | 11 +- RELEASE_NOTES.md | 7 + SPEC.POWERSHELL.md | 162 ++++++++++--- SPEC.md | 39 +++- docs/CONSUMER_GUIDE.md | 41 +++- .../v0-3-structured-shell-analysis/design.md | 69 ++++++ .../proposal.md | 8 + .../specs/consumer-compatibility/spec.md | 31 +++ .../specs/structured-shell-syntax/spec.md | 84 +++++++ .../v0-3-structured-shell-analysis/tasks.md | 30 +++ .../Pwsh/Parsing/PwshCommandParser.cs | 73 +++++- .../Parsing/PwshForEachStructuralParser.cs | 2 +- .../Pwsh/Parsing/PwshForEachValueAnalysis.cs | 69 ++++-- .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 13 +- .../Internal/Pwsh/Verbs/PwshAliases.cs | 134 +++++++++-- .../PwshExecutionRegionBindingCatalog.cs | 20 +- src/ShellSyntaxTree/PwshParser.cs | 2 +- src/ShellSyntaxTree/PwshParserOptions.cs | 22 ++ .../Corpus/CorpusRunnerTests.cs | 20 +- .../Corpus/PwshOracleTests.cs | 130 ++++++++--- ..._v03_pwsh_payload_stays_bash_argument.json | 136 +++++++++++ ...owershell_pipeline_chain_syntax_error.json | 10 + ...493_v03_windows_powershell_curl_alias.json | 101 ++++++++ ..._powershell_parallel_receiver_unknown.json | 212 +++++++++++++++++ .../495_v03_windows_powershell_wmi_alias.json | 101 ++++++++ ...windows_powershell_no_get_error_alias.json | 81 +++++++ ...ndows_powershell_nested_chain_foreach.json | 10 + ..._powershell_nested_chain_direct_block.json | 10 + ...powershell_nested_chain_command_block.json | 10 + ..._powershell_nested_chain_substitution.json | 10 + ...ws_powershell_nested_chain_child_host.json | 11 + ...ash_payload_stays_powershell_argument.json | 30 +++ ...rshell_nested_chain_invoke_expression.json | 11 + .../Parsing/ParserLanguageBoundaryTests.cs | 217 ++++++++++++++++++ .../PwshExecutionRegionBindingCatalogTests.cs | 19 +- .../PublicApiSnapshotTests.cs | 29 ++- .../V03PublicApiSnapshotTests.cs | 1 + tools/PwshCorpusTool/CorpusJson.cs | 8 +- tools/PwshCorpusTool/CorpusManifest.cs | 59 ++++- tools/PwshCorpusTool/Program.cs | 58 +++-- tools/PwshCorpusTool/PwshOracle.cs | 146 +++++++++--- 44 files changed, 2100 insertions(+), 210 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/293_v03_pwsh_payload_stays_bash_argument.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/492_v03_windows_powershell_pipeline_chain_syntax_error.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/493_v03_windows_powershell_curl_alias.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/494_v03_windows_powershell_parallel_receiver_unknown.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/495_v03_windows_powershell_wmi_alias.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/496_v03_windows_powershell_no_get_error_alias.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/497_v03_windows_powershell_nested_chain_foreach.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/498_v03_windows_powershell_nested_chain_direct_block.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/499_v03_windows_powershell_nested_chain_command_block.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/500_v03_windows_powershell_nested_chain_substitution.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/501_v03_windows_powershell_nested_chain_child_host.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/502_v03_bash_payload_stays_powershell_argument.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/503_v03_windows_powershell_nested_chain_invoke_expression.json create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 45623fb..7fe4964 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -40,14 +40,36 @@ jobs: shell: pwsh run: ./scripts/Add-FileHeaders.ps1 -Verify - - name: "Verify pwsh is available (SPEC.POWERSHELL.md §13 oracle gate)" + - name: "Verify compatible PowerShell 7.6 is available (SPEC.POWERSHELL.md §13 oracle gate)" shell: bash run: | if ! command -v pwsh >/dev/null 2>&1; then echo "::error::pwsh is not on PATH — the PowerShell corpus oracle gate (PwshOracleTests) would silently skip." exit 1 fi - pwsh --version + pwsh -NoProfile -NoLogo -NonInteractive -Command ' + $v = $PSVersionTable.PSVersion + if ($v -lt [version]"7.6.4" -or $v -ge [version]"7.7") { + throw "Expected PowerShell >= 7.6.4 and < 7.7, found $v" + } + $v.ToString() + ' + + - name: "Verify Windows PowerShell 5.1 is available" + if: runner.os == 'Windows' + shell: bash + run: | + if ! command -v powershell.exe >/dev/null 2>&1; then + echo "::error::powershell.exe is not on the Bash PATH used by dotnet test — the Windows PowerShell 5.1 oracle would silently skip." + exit 1 + fi + powershell.exe -NoProfile -NoLogo -NonInteractive -Command ' + $v = $PSVersionTable.PSVersion + if ($v.Major -ne 5 -or $v.Minor -ne 1) { + throw "Expected Windows PowerShell 5.1, found $v" + } + $v.ToString() + ' - name: "dotnet restore" run: dotnet restore diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 439ec38..37eacac 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -10,13 +10,27 @@ priorities. --- -## NOW (0.2.0 downstream acceptance / 0.3.0 contract design) +## NOW (0.3.0 host integration and release acceptance) > **Spec:** `SPEC.POWERSHELL.md` (v0.2.0). The PowerShell parser is > implemented — phases 1–14 of `SPEC.POWERSHELL.md` §16 are complete (see > below). What remains is the downstream Netclaw integration, which needs > actions outside this repository. +- [x] **v0.3 host-selected grammar and PowerShell dialect — library slice.** The executor + selects one top-level parser; Bash never cross-parses `pwsh` payloads and + PowerShell never cross-parses `bash -c` payloads. Add the extend-only + `PwshDialect` option with PowerShell 7 as the compatibility default and + Windows PowerShell 5.1 as an explicit native-Windows fallback. Dialect- + local syntax/catalog behavior and paired direct/corpus coverage are + implemented. Windows CI must still prove both live oracles before this + slice merges. + +- [ ] **v0.3 native-Windows Netclaw integration.** Pass the exact selected + shell through Netclaw's executor, approval policy, and model context; + prefer a compatible `pwsh.exe`, fall back to `powershell.exe`, and + reparse and reauthorize if executable selection changes. + - [ ] **v0.3 authored-command approval correction.** Treat PowerShell and Bash approval completeness consistently: prove every authored executable region, but do not require proof of ambient aliases, functions, modules, @@ -599,18 +613,20 @@ priorities. [NuGet package](https://www.nuget.org/packages/ShellSyntaxTree/0.3.0-alpha.3) and [GitHub prerelease](https://github.com/Aaronontheweb/ShellSyntaxTree/releases/tag/0.3.0-alpha.3) preserve the v0.2 projection and the existing public v0.3 API. -- [ ] Publish `0.3.0-alpha.4` with the reviewed authored-command completeness - correction. Netclaw must validate default-mode static PowerShell commands - without requiring ambient profile, module, alias, function, `PATH`, - inherited-variable, or prior-runspace proofs. Unknown and source-mutated - policy facts must remain strict. +- [x] Published `0.3.0-alpha.4` with the reviewed authored-command completeness + correction. The + [NuGet package](https://www.nuget.org/packages/ShellSyntaxTree/0.3.0-alpha.4) + and [GitHub prerelease](https://github.com/Aaronontheweb/ShellSyntaxTree/releases/tag/0.3.0-alpha.4) + preserve strict unknown and source-mutated policy facts while default-mode + static PowerShell commands no longer require ambient resolution proof. - [x] Replace the pre-alpha consumer preview with the v0.3 occurrence-based authorization loop and separate syntax-display guidance. Document exact, finite, pattern, unknown, joined-cwd, redirect, incomplete-result, equality, hashing, `ToString()`, serialization, and `Clauses` migration behavior in the guide and release notes; direct the README quick start to `Commands` and the full guide. -- [x] Close the v0.3 public-API compatibility gate. Existing reflection +- [x] Re-closed the v0.3 public-API compatibility gate for the additive + `PwshDialect` enum and options property. Existing reflection snapshots pin the exact exported types, members, enum ordering, reference nullability, defaults, parser constructors and entry points, and fixed limits against the shared and PowerShell specifications. @@ -618,6 +634,11 @@ priorities. plus equal-record hash consistency, demonstrate that default JSON is not a polymorphic round-trip contract, and make every policy-sensitive unknown numeric enum value detectable so consumers can reject it. + The dialect slice adds explicit default, unknown-value, record equality, + hash, `ToString()`, propagation, and parser-behavior coverage. A + `dotnet-inspect` assembly diff against the published 0.3.0-alpha.4 package + reports exactly two additive changes on both `net8.0` and + `netstandard2.0`: the enum and one options member, with no breaking change. - [x] Expose public Bash heredoc body, delimiter, expansion, tab-stripping, and completeness facts from the delivered bounded grammar. Direct tests pin literal and expanding delimiters, every supported substitution command, diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 8528964..057bae1 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -24,8 +24,9 @@ The output is a `ParsedCommand` containing: elements; executable-specific semantics remain consumer-owned - Bash `cd && cmd` and PowerShell `Set-Location ; cmd` propagation — the target is attributed to subsequent clauses -- recursion into `bash -c`, `pwsh -Command`, and `pwsh -EncodedCommand` so - wrapped commands surface as clauses +- parser-local recursion into Bash `bash -c` or PowerShell `pwsh -Command` / + `pwsh -EncodedCommand`; an external shell invoked from the other language + remains an ordinary command and its payload is not cross-parsed - PowerShell alias canonicalization and explicit dynamic-command identity - Bash subshell isolation and PowerShell grouping semantics for cwd attribution - safe-fail flag `IsUnparseable` for unsupported constructs (control flow, @@ -70,10 +71,15 @@ zero-native-deps .NET parser sized to what security gates actually need. - Bash and PowerShell 7 pipeline parsing ship behind the shared `IShellParser` seam. Windows `cmd` remains deferred. +- Stable v0.3 keeps PowerShell 7 as the compatibility default and adds an + explicit Windows PowerShell 5.1 dialect for native-Windows fallback. The + executor, parser, approval policy, and model context must agree on the exact + selected shell; ShellSyntaxTree does not auto-detect it. - Public API surface in SPEC §2 is **locked**. Internal changes are free. - Acceptance is the multi-shell corpus contract: every Bash and PowerShell JSON entry parses to its expected AST, and the PowerShell corpus also passes - the live `pwsh` oracle matrix. + the dialect-matched live oracle matrix (`pwsh` for PowerShell 7 and + `powershell.exe` for Windows PowerShell 5.1 on Windows CI). ### v0.3 (contract design) diff --git a/README.md b/README.md index f63ca22..9ba6949 100644 --- a/README.md +++ b/README.md @@ -126,7 +126,8 @@ public sealed class PwshParser : IShellParser { /* … */ } // v0.2.0 public abstract record ShellParserOptions { /* HomeDirectory, WorkingDirectory */ } public sealed record BashParserOptions : ShellParserOptions; // InitialStateMode -public sealed record PwshParserOptions : ShellParserOptions; // InitialStateMode +public sealed record PwshParserOptions : ShellParserOptions; // InitialStateMode, Dialect +public enum PwshDialect { Unknown, PowerShell7, WindowsPowerShell51 } public sealed record ParsedCommand { /* Source, Syntax, Commands, Clauses, IsUnparseable, … */ } public abstract record ShellSyntaxNode; @@ -151,6 +152,14 @@ v0.2 consumers can migrate from the conservative `Clauses` projection. The shell-specific parsers retain different grammar and analysis rules. A Windows `cmd` parser remains deferred. +Select the parser from the shell that will actually execute the source. The +library does not auto-detect or cross-parse languages: `pwsh -Command ...` +under `BashParser` is an ordinary external command. `PwshParserOptions.Dialect` +defaults to PowerShell 7 for compatibility; select `WindowsPowerShell51` +explicitly when the executor falls back to `powershell.exe`. The +`PowerShell7` currently denotes the proved PowerShell 7.6 servicing line: +version 7.6.4 or newer, but earlier than 7.7. + Behavioral contract: [`SPEC.md`](./SPEC.md) (bash + shared surface) and [`SPEC.POWERSHELL.md`](./SPEC.POWERSHELL.md) (PowerShell). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 0462561..41254b0 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -11,6 +11,13 @@ ShellSyntaxTree does not define a stable serialized wire format. Persisted results require a consumer-owned, versioned DTO or explicit serializer mapping that fails closed on unknown node and enum values. +- `PwshParserOptions.Dialect` is additive and defaults to `PowerShell7` for + compatibility. Native Windows consumers select it only for a compatible + PowerShell 7.6 host (`>=7.6.4` and `<7.7`) and select + `WindowsPowerShell51` when falling back to `powershell.exe`; the host, + parser dialect, approval policy, and executor identity must agree. The new + property also participates in options-record equality, hashing, `ToString()`, + reflection, and default serialization shape. #### 0.3.0-alpha.4 2026-08-09 #### diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index c3dfeae..53f678b 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -25,12 +25,15 @@ 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. -**Reference dialect.** This spec targets **PowerShell 7.x** (7.4 LTS) as the -reference dialect — the cross-platform `pwsh` executable, not Windows -PowerShell 5.1. The `&&` / `||` pipeline-chain operators (§4, added in -PowerShell 7.0), the default alias set (§6.3), and the redirect stream -syntax (§5) are all PowerShell 7 semantics. The `pwsh` validation oracle -(§13) MUST run a 7.x build. +**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. --- @@ -122,10 +125,20 @@ public enum PwshInitialStateMode IsolatedNonInteractiveNoProfile, } +/// Selects the PowerShell grammar and versioned metadata. +public enum PwshDialect +{ + Unknown, + // PowerShell 7.6 servicing releases from 7.6.4. + PowerShell7, + WindowsPowerShell51, +} + /// Configuration knobs for PwshParser. public sealed record PwshParserOptions : ShellParserOptions { public PwshInitialStateMode InitialStateMode { get; init; } + public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7; } /// PowerShell implementation of IShellParser. @@ -145,7 +158,8 @@ The shared v0.2 AST gains the following changes (see §3): `ClauseElement` and `ClauseElementRole` define its entries. - `Clause.IsBashCWrapped` is renamed `Clause.IsCommandStringWrapped`. -**Versioning.** `PwshParser`, `PwshParserOptions`, `ShellParserOptions`, +**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 @@ -154,6 +168,39 @@ 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. --- @@ -258,6 +305,9 @@ 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. --- @@ -530,6 +580,14 @@ The version-pinned PowerShell 7 catalog covers: | `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 @@ -822,29 +880,34 @@ pattern-prefix match. ### 6.3 Built-in alias table -`PwshAliases` is a static, case-insensitive map from a typed alias to its -canonical cmdlet. When the first token is a known alias the parser: +`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** — it is the single most +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 the -reference PowerShell 7.x build — not a hand-picked subset. An alias absent +`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 a `[Fact]` (the §13 `pwsh` oracle already -spawns `pwsh`) diffs `PwshAliases` against live `Get-Alias` output and fails -on any gap. The table below is the **security-relevant excerpt** (file, +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 | @@ -878,10 +941,20 @@ cwd, and code-execution verbs), not the whole table: **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 alias table wins for known aliases. -3. `curl`, `wget`, `sc`, `set`, `start`, and `where` are treated as **native - commands**, never aliased — their cmdlet vs. native-tool meaning is - version-dependent and aliasing would mis-apply cmdlet semantics. +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` / `wget` → `Invoke-WebRequest`, `sc` → `Set-Content`, + `set` → `Set-Variable`, `start` → `Start-Process`, and `where` → + `Where-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 `gwmi` → `Get-WmiObject`, `asnp` → `Add-PSSnapIn`, +and `trcm` → `Trace-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 @@ -1061,8 +1134,9 @@ positionals are paths," exactly as `SPEC.md` §7. ### 7.3 Native commands -Native commands reuse the bash per-verb rules table verbatim — `git`, -`curl`, `tar`, etc. behave identically to `SPEC.md` §7 (`curl` / `wget`: +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` / @@ -1078,6 +1152,9 @@ tokenization: spaced curl operands beginning with `@` should be quoted because `--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. --- @@ -1621,26 +1698,36 @@ PowerShell-specific deltas: 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/` with `PwshParser`. The +`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//` directory rather than a hard-coded `bash` path. ### Schema additions -The shared corpus DTO gains two optional fields: +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 — - real `pwsh` must also reject it) or `OutOfScope` (valid PowerShell the - parser deliberately does not model — real `pwsh` must accept it). Defaults + 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 real `pwsh` parses the *outer* invocation without error. + — 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, @@ -1672,26 +1759,33 @@ 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 `pwsh` validation gate +### The dialect-matched PowerShell validation gate -A CI test feeds every PowerShell corpus `input` to the real PowerShell parser +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` invocation and enforces: +child process: `pwsh` for `PowerShell7` and `powershell.exe` for +`WindowsPowerShell51`. It enforces: -| `isUnparseable` | `oracleExpectation` | real `pwsh` must report | +| `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 ground truth; it is **not** a +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 real `pwsh` verdict; the tool is registered -in `TOOLING.md`. A developer without `pwsh` on `PATH` sees the gate skipped; -CI installs `pwsh` and an explicit step fails loudly if it is absent. +`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. --- diff --git a/SPEC.md b/SPEC.md index ab79bc9..661b0e5 100644 --- a/SPEC.md +++ b/SPEC.md @@ -62,6 +62,21 @@ command can consume it. - Performance tuning beyond "fast enough to invoke per shell call without noticeable latency" (~1ms per typical input). +### Parser selection and language boundary (v0.3) + +The execution environment selects exactly one top-level parser. Consumers use +`BashParser` only when Bash will execute the submitted source and `PwshParser` +only when PowerShell will execute it. Neither parser guesses a language from +command text or delegates an argument payload to the other parser. + +Therefore Bash input such as `pwsh -Command 'Get-Content x'` remains one +ordinary external `pwsh` command with a Bash argument; it does not surface a +PowerShell child command. PowerShell input such as `bash -c 'rm x'` likewise +remains one ordinary external `bash` command. Same-language wrapper recursion +remains parser-local: Bash owns supported `bash` / `sh -c` recursion, while +PowerShell owns its PowerShell-host and static `Invoke-Expression` recursion. +The library never auto-detects the host shell or probes the machine. + --- ## 2. Public API Surface @@ -130,12 +145,29 @@ public enum PwshInitialStateMode IsolatedNonInteractiveNoProfile, } +/// Selects the PowerShell grammar and versioned metadata. +public enum PwshDialect +{ + Unknown, + // PowerShell 7.6 servicing releases from 7.6.4; versioned tables are pinned. + PowerShell7, + WindowsPowerShell51, +} + /// Configuration knobs for PwshParser. public sealed record PwshParserOptions : ShellParserOptions { public PwshInitialStateMode InitialStateMode { get; init; } + public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7; } +`PwshDialect` and `PwshParserOptions.Dialect` are source- and binary-additive. +The property initializer preserves the released PowerShell 7 parser semantics +for existing constructors and object initializers. Like every additive public +record property, it deliberately changes generated equality, hashing, +`ToString()`, reflection, and default serializer shape; parser results and +options are not a stable implicit wire format. + // The pre-v0.2.0 BashParserOptions body, now hoisted onto ShellParserOptions: public abstract record ShellParserOptions { @@ -1038,10 +1070,11 @@ public sealed record Clause public bool IsSubshell { get; init; } /// - /// True when this clause is the result of recursing into a - /// command-string wrapper — `bash -c "..."` / `sh -c "..."`, or (v0.2.0) + /// True when this clause is the result of parser-local recursion into a + /// command-string wrapper — Bash `bash -c "..."` / `sh -c "..."`, or /// PowerShell `pwsh -Command "..."` / `pwsh -EncodedCommand ...` / - /// static `Invoke-Expression '...'`. Useful + /// static `Invoke-Expression '...'`. One parser never delegates wrapper + /// payloads to the other parser. Useful /// for consumers that want to surface "this came from a wrapped /// invocation" in UI. /// diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 5cc10e4..e646206 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -85,13 +85,43 @@ static IShellParser CreateParser(string shell, string workingDirectory) => "pwsh" => new PwshParser(new PwshParserOptions { WorkingDirectory = workingDirectory, + Dialect = PwshDialect.PowerShell7, + }), + "powershell" => new PwshParser(new PwshParserOptions + { + WorkingDirectory = workingDirectory, + Dialect = PwshDialect.WindowsPowerShell51, }), _ => throw new ArgumentOutOfRangeException(nameof(shell)), }; ``` Do not guess the shell from the command text. `rm`, `cd`, quoting, redirects, -and grouping can mean different things in Bash and PowerShell. +and grouping can mean different things in Bash and PowerShell. Select the +parser and PowerShell dialect from the executor before parsing. If executor +selection changes, update the model context and parse the source again; do not +silently execute it under a fallback shell after authorizing another grammar. + +Parser selection is not recursive language detection. Under `BashParser`, +`pwsh -Command 'Get-Content x'` is one ordinary external command and the +payload remains a Bash argument. Under `PwshParser`, `bash -c 'rm x'` is one +ordinary external command. Consumers that deliberately compose languages need +a separate policy contract; ShellSyntaxTree v0.3 does not cross-parse them. +Generic operand heuristics may still classify a quoted outer payload as +path-shaped. A consumer may apply a constrained `pwsh` / `bash` executable +argument grammar or strict authored matching to that outer command, but it +must not reinterpret the payload language or treat generic path metadata as +cross-language proof. + +The PowerShell dialect also affects versioned syntax and classification. For +example, Windows PowerShell 5.1 treats unqualified `curl` and `wget` as aliases +for `Invoke-WebRequest` and retains `gwmi` as `Get-WmiObject`, while PowerShell +7 does not. Conversely, `gerr` is the PowerShell 7 `Get-Error` alias and is not +an alias in Windows PowerShell 5.1. Never parse under one dialect and execute +under the other. `PwshDialect.PowerShell7` requires a +PowerShell 7.6 servicing executable at least 7.6.4 but earlier than 7.7; +verify both bounds during executor selection rather than asking the parser to +probe the machine. Once parsed, a security-oriented consumer normally follows this sequence: @@ -268,6 +298,7 @@ var parser = new PwshParser(new PwshParserOptions { WorkingDirectory = workingDirectory, InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + Dialect = PwshDialect.PowerShell7, }); ``` @@ -639,10 +670,14 @@ A UI may group a pipeline as one approval prompt, but authorization should still inspect every stage. `download | sh` is unsafe even if `download` alone is allowed. -ShellSyntaxTree also looks through supported command-string wrappers. Clauses -surfaced from `bash -c`, `pwsh -Command`, and `pwsh -EncodedCommand` carry +Each parser also looks through its own supported command-string wrappers. +Clauses surfaced by `BashParser` from `bash -c`, or by `PwshParser` from +`pwsh -Command` and `pwsh -EncodedCommand`, carry `IsCommandStringWrapped = true`. The outer wrapper is not the action a verb-based policy should authorize; the surfaced inner clauses are. +This is same-language recursion only. A `pwsh` executable seen by `BashParser`, +or a `bash` executable seen by `PwshParser`, remains an ordinary external +command with no cross-language child occurrences. Redirects authored on the outer PowerShell wrapper remain attached to the last surfaced clause, so redirect policy still sees paths such as `pwsh -Command "git status" > audit.log`. diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 802c496..2b1b293 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -115,6 +115,75 @@ consumption, error recovery, quoting, and expression boundaries that already differ between the two shells. Shared components are composed as explicit classifiers and analysis passes instead. +### Select one top-level grammar from the execution environment + +The consumer selects `BashParser` or `PwshParser` from the shell that will +actually execute the submitted source. The parser does not infer a language +from command names, payload strings, quoting, the host operating system, or an +executable discovered inside another shell. + +Consequently, `pwsh -Command 'Get-Content x'` submitted to Bash remains one +Bash-parsed external `pwsh` invocation whose payload is an argument. Bash does +not call `PwshParser`. Likewise, `bash -c 'rm x'` submitted to PowerShell +remains one PowerShell-parsed external `bash` invocation. PowerShell does not +call `BashParser`. This prevents a policy from authorizing a different grammar +than the executor will apply and avoids recursively interpreting arbitrary +data merely because an executable name resembles another shell. + +Existing command-string recursion is parser-local. `BashParser` may recurse +only into supported Bash `bash` / `sh -c` wrappers. `PwshParser` may recurse +only into supported PowerShell host wrappers and static `Invoke-Expression` +payloads. Cross-language composition, if ever added, requires a separate +consumer-visible contract and is not part of v0.3. + +### Make the PowerShell dialect explicit and extend only + +`PwshParserOptions` gains one additive property and a new enum: + +```csharp +public enum PwshDialect +{ + Unknown, + PowerShell7, + WindowsPowerShell51, +} + +public sealed record PwshParserOptions : ShellParserOptions +{ + public PwshInitialStateMode InitialStateMode { get; init; } + public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7; +} +``` + +The initializer preserves the behavior of both existing constructors and +existing object initializers. Zero remains `Unknown` for forward-compatible +enum handling, but the option defaults to `PowerShell7`, matching every +released `PwshParser`. An explicit unknown or unrecognized value makes the +whole result unparseable with empty authorization projections. + +The dialect is grammar and catalog input, not executable discovery. PowerShell +7 uses the PowerShell 7.6 servicing line from 7.6.4 (`>=7.6.4` and `<7.7`), +matching the pinned syntax, alias, parameter-binding, and execution-region +receiver tables. Consumers MUST NOT select this dialect for PowerShell 7.7 or +later because the parser does not probe the executable and a new default alias +could otherwise be misclassified as a native command. A later minor line +requires an independently proved dialect contract. +Windows PowerShell 5.1 accepts only syntax +and metadata proved for that edition. In particular, `&&` / `||` and +`ForEach-Object -Parallel` cannot receive PowerShell 7 proof in 5.1 mode, and +the 5.1 alias catalog must not be inferred from the PowerShell 7 process. +Dialect-matched `Get-Alias` oracles compare canonical definitions as well as +names: 5.1 restores removed aliases such as `gwmi` while excluding later +PowerShell 7 aliases such as `gerr`. + +Inside a PowerShell parse, a statically recognized PowerShell host wrapper may +select the dialect of its decoded child: `pwsh` / `pwsh.exe` selects +`PowerShell7`, while `powershell` / `powershell.exe` selects +`WindowsPowerShell51`. Dynamic or ambiguous host identity remains visible and +incomplete instead of choosing a dialect. A consumer still selects the +top-level dialect from its executor before parsing; the parser never probes +the machine. + ### Preserve resolver-relevant fragments through decoding The v0.2 lexer-to-resolver contract is too weak for a security parser. A diff --git a/openspec/changes/v0-3-structured-shell-analysis/proposal.md b/openspec/changes/v0-3-structured-shell-analysis/proposal.md index b1aa288..5c7dd45 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/proposal.md +++ b/openspec/changes/v0-3-structured-shell-analysis/proposal.md @@ -16,6 +16,14 @@ fail-closed behavior for incomplete analysis. - Add a strongly typed syntax-node hierarchy above the existing `Clause` leaf model, with shell-specific front ends producing one shared structural contract where their semantics actually coincide. +- Make the execution environment select exactly one top-level shell grammar. + A Bash parse never delegates a `pwsh` argument to the PowerShell parser, and + a PowerShell parse never delegates a `bash -c` argument to the Bash parser. + Same-language command-string recursion remains parser-local. +- Add an explicit PowerShell dialect option. Preserve PowerShell 7 as the + compatibility default, add Windows PowerShell 5.1 for the native-Windows + fallback, and fail closed rather than borrowing syntax, aliases, or command + metadata from the wrong dialect. - Add a library-owned command-occurrence projection containing every command that may execute in supported grammar, including iterator, loop-body, wrapped, substitution, and PowerShell script-block execution-region commands. diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md index 023f6ec..2b6ea6b 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md @@ -180,3 +180,34 @@ or explicit serializer configuration. - **WHEN** a consumer needs to store or transmit a v0.3 parser result - **THEN** it does not assume the closed record hierarchy is an implicit stable JSON union - **THEN** it owns an explicit versioned representation or serializer mapping + +### Requirement: Native Windows shell identity is consistent end to end +Netclaw SHALL select one canonical native-Windows shell environment before +producing or authorizing command text. It SHALL prefer a compatible `pwsh.exe` +and SHALL fall back to Windows `powershell.exe` only when PowerShell 7 is not +available. The selected platform, executable, and `PwshDialect` SHALL remain +identical across LLM context, parser construction, approval policy, and process +execution. + +If the selected executable becomes unavailable or fallback selection changes, +Netclaw SHALL update the LLM/execution context and reparse the submitted source +under the replacement dialect before execution. It SHALL NOT execute source +that was authorized under a different grammar. + +#### Scenario: Compatible PowerShell 7 is available on Windows +- **WHEN** Netclaw selects the native Windows shell and a supported `pwsh.exe` is available +- **THEN** model context names that executable and PowerShell 7 +- **THEN** approval uses `PwshDialect.PowerShell7` +- **THEN** execution invokes the same `pwsh.exe` + +#### Scenario: Windows PowerShell is the fallback +- **WHEN** no supported `pwsh.exe` is available but Windows PowerShell 5.1 is available +- **THEN** model context names `powershell.exe` and Windows PowerShell 5.1 +- **THEN** approval uses `PwshDialect.WindowsPowerShell51` +- **THEN** execution invokes that same Windows PowerShell host + +#### Scenario: Shell selection changes before execution +- **WHEN** the previously selected executable cannot be used +- **THEN** Netclaw selects a replacement environment and updates model context +- **THEN** it reparses and reauthorizes the source under the replacement dialect +- **THEN** it never silently executes the earlier authorization under another shell diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md index 10cbc5c..9c54dea 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md @@ -1,5 +1,89 @@ ## ADDED Requirements +### Requirement: The execution environment selects one top-level grammar +The consumer SHALL select exactly one parser from the shell that will execute +the submitted source. A parser SHALL NOT auto-detect another shell from an +executable name or delegate payload text to a different shell parser. +Same-language command-string recursion SHALL remain owned by the selected +parser and SHALL NOT imply cross-language parsing. + +#### Scenario: PowerShell host invoked by Bash stays a Bash command +- **WHEN** `BashParser` parses `pwsh -NoProfile -Command 'Get-Content input.txt | Set-Content output.txt'` +- **THEN** it exposes one ordinary external `pwsh` command occurrence +- **THEN** the quoted payload remains a Bash argument +- **THEN** no `Get-Content` or `Set-Content` child occurrence is invented + +#### Scenario: Bash host invoked by PowerShell stays a PowerShell command +- **WHEN** `PwshParser` parses `bash -c 'rm target.txt'` +- **THEN** it exposes one ordinary external `bash` command occurrence +- **THEN** the quoted payload remains a PowerShell argument +- **THEN** no Bash `rm` child occurrence is invented + +#### Scenario: Same-language wrapper recursion remains local +- **WHEN** the selected parser encounters one of its supported static command-string wrappers +- **THEN** it may surface decoded child commands using the same shell family +- **THEN** it does not inspect that child for another shell language + +### Requirement: PowerShell parsing uses an explicit dialect +`PwshParserOptions` SHALL expose a `PwshDialect` option with `Unknown=0`, +`PowerShell7=1`, and `WindowsPowerShell51=2`. The option SHALL default to +`PowerShell7` so existing constructors and object initializers retain their +released behavior. The parser SHALL NOT discover or infer a dialect from the +local machine. + +Each dialect SHALL use only grammar, aliases, parameter bindings, and +execution-region receiver facts proved for that edition. `Unknown`, an +unrecognized future enum value, or unsupported grammar in the selected dialect +SHALL make the result unparseable with empty command and compatibility +projections. A completely delimited execution region whose receiver metadata +is unavailable for the selected dialect SHALL remain visible and incomplete; +it is not an unavailable grammar production. + +#### Scenario: Existing callers retain PowerShell 7 behavior +- **WHEN** a consumer constructs `new PwshParser()` or omits `Dialect` from `PwshParserOptions` +- **THEN** the selected dialect is `PowerShell7` +- **THEN** existing PowerShell 7 corpus behavior is unchanged + +#### Scenario: Windows PowerShell rejects PowerShell 7 pipeline chains +- **WHEN** `WindowsPowerShell51` parses `Get-Item a && Get-Item b` +- **THEN** the result is unparseable with empty authorization projections +- **WHEN** `PowerShell7` parses the same source +- **THEN** the existing pipeline-chain structure is retained + +#### Scenario: Dialect grammar applies inside every recursively parsed region +- **WHEN** `WindowsPowerShell51` encounters `&&` or `||` inside a loop body, direct block, command-owned block, substitution, static expression, or decoded `powershell.exe` payload +- **THEN** the entire result is unparseable with empty authorization projections +- **THEN** no nested parser path silently accepts PowerShell 7 grammar + +#### Scenario: Dialect-specific receiver metadata does not leak +- **WHEN** `WindowsPowerShell51` parses `ForEach-Object -Parallel { Get-Date }` +- **THEN** PowerShell 7-only `-Parallel` receiver semantics are not published +- **THEN** the host and completely delimited body remain visible and incomplete + +#### Scenario: Dialect-specific aliases do not leak +- **WHEN** `WindowsPowerShell51` parses the unqualified command `curl example.test` +- **THEN** its versioned default-alias classification identifies `Invoke-WebRequest` +- **WHEN** `PowerShell7` parses the same authored command +- **THEN** it remains a native `curl` spelling rather than borrowing the Windows PowerShell alias + +#### Scenario: Edition-only aliases stay in their owning dialect +- **WHEN** `WindowsPowerShell51` parses `gwmi Win32_OperatingSystem` +- **THEN** its versioned default-alias classification identifies `Get-WmiObject` +- **WHEN** `WindowsPowerShell51` parses `gerr` +- **THEN** it remains a native spelling rather than borrowing PowerShell 7's `Get-Error` alias + +#### Scenario: Static PowerShell child host selects its own dialect +- **WHEN** a PowerShell parse recursively decodes a static `pwsh -Command` child +- **THEN** the child uses `PowerShell7` +- **WHEN** it recursively decodes a static `powershell.exe -Command` child +- **THEN** the child uses `WindowsPowerShell51` +- **THEN** dynamic or ambiguous host identity does not select either dialect + +#### Scenario: Current-scope analysis preserves the selected dialect +- **WHEN** Windows PowerShell 5.1 parses a group, substitution, loop, redirect, or resolver-sensitive value +- **THEN** every internal options clone and current-scope recursive parse retains `WindowsPowerShell51` +- **THEN** only a statically recognized PowerShell child-host wrapper may select a different dialect + ### Requirement: Parsed commands expose authored nested structure Every fully parsed command SHALL expose one library-owned syntax root that preserves the authored nesting and source order of supported command lists, diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index b288a99..d7b7071 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -21,6 +21,10 @@ process/runspace, initialization, proved data, and unknown receiver cases before production implementation. Deferred-action cases remain design-only evidence until separately promoted after v0.3. +- [x] 1.13 Lock host-selected single-grammar parsing and the additive + `PwshDialect` contract. Preserve PowerShell 7 as the compatibility default, + make Windows PowerShell 5.1 explicit, keep Bash and PowerShell top-level + parsing separate, and require dialect-local grammar/catalog proof. ## 2. Resolver Provenance Correction and Shared Preparation @@ -268,6 +272,32 @@ - `PwshCorpusTool` now supports case-specific `PwshInitialStateMode`; keep promoting the remaining stable execution-region and adversarial cases into its generated manifest, then add the Netclaw PowerShell policy matrix. +- [x] 7.8 Implement the additive `PwshDialect` API, PowerShell 7 compatibility + default, unknown-value safe-fail, Windows PowerShell 5.1 pipeline-chain + rejection, dialect-specific alias and execution-region metadata, and static + PowerShell child-host dialect selection without cross-language delegation. + - [x] 7.8a Add the public enum/property, default and unknown-value behavior, + current-scope propagation, 5.1 pipeline-chain rejection and default aliases, + conservative 5.1 receiver handling, and static child-host dialect switching. + - [x] 7.8b PARKED post-v0.3: promote additional Windows PowerShell 5.1 + execution-receiver and parameter metadata only after the matching + `powershell.exe` oracle proves it. The stable v0.3 catalog remains + deliberately conservative, so this optional expansion does not gate 7.8. +- [ ] 7.9 Add direct and executable-corpus coverage for both parser boundaries + and both PowerShell dialects. Validate PowerShell 7 cases with `pwsh`, + Windows PowerShell 5.1 cases with `powershell.exe` on Windows CI, and keep + unsupported or unavailable oracle states explicit rather than silently + borrowing results from the other edition. + - [x] 7.9a Pin public record behavior, both language boundaries, dialect + propagation and switching, 5.1 aliases, pipeline-chain rejection, and + conservative receiver behavior in direct tests and dialect-routed corpus. + - [ ] 7.9b Prove the dialect-selected corpus and alias oracles on Windows CI, + including `powershell.exe` discovery from the Bash environment that runs + `dotnet test`. +- [ ] 7.10 Migrate Netclaw's native Windows environment to prefer a compatible + `pwsh.exe`, fall back to `powershell.exe`, and carry one canonical platform, + executable, and dialect identity through LLM context, parser, approval + policy, and executor. Reparse and reauthorize if fallback selection changes. ## 10. Heredoc / Here-String Slice diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 13dfc1b..c5fd7e8 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -42,6 +42,13 @@ internal static ParsedCommand Parse(string source, PwshParserOptions options) throw new ArgumentNullException(nameof(options)); } + if (!IsSupportedDialect(options.Dialect)) + { + return Unparseable( + source, + $"unsupported PowerShell dialect value {(int)options.Dialect}"); + } + return ParseInternal( source, options, recursionDepth: 0, structuralDepth: 0, markWrapped: false, sharedLocation: null); @@ -85,7 +92,7 @@ private static ParsedCommand ParseInternal( return new ParsedCommand { Source = source, Clauses = Array.Empty() }; } - if (TryDetectAnomaly(significant, out var anomalyReason)) + if (TryDetectAnomaly(significant, options.Dialect, out var anomalyReason)) { return Unparseable(source, anomalyReason); } @@ -108,6 +115,23 @@ private static ParsedCommand ParseInternal( UnparseableReason = reason, }; + private static bool IsSupportedDialect(PwshDialect dialect) => + dialect is PwshDialect.PowerShell7 or PwshDialect.WindowsPowerShell51; + + private static bool ContainsPipelineChainOperator(IReadOnlyList tokens) + { + foreach (var token in tokens) + { + if (token.Kind == PwshTokenKind.Operator && + token.OperatorText is "&&" or "||") + { + return true; + } + } + + return false; + } + // ---------------------------------------------------------------- filtering private static List FilterSignificant(IReadOnlyList tokens) @@ -158,15 +182,25 @@ private static List FilterSignificant(IReadOnlyList tokens // ---------------------------------------------------------------- anomalies - private static bool TryDetectAnomaly(IReadOnlyList tokens, out string? reason) + private static bool TryDetectAnomaly( + IReadOnlyList tokens, + PwshDialect dialect, + out string? reason) { + if (dialect == PwshDialect.WindowsPowerShell51 && + ContainsPipelineChainOperator(tokens)) + { + reason = "PowerShell pipeline-chain operators require the PowerShell 7 dialect"; + return true; + } + // Item 3: control-flow / definition / block keyword at a verb slot. if (TryDetectKeywordAnomaly(tokens, out reason)) { return true; } - if (TryDetectUnsupportedInvocationShape(tokens, out reason)) + if (TryDetectUnsupportedInvocationShape(tokens, dialect, out reason)) { return true; } @@ -188,7 +222,9 @@ private static bool TryDetectAnomaly(IReadOnlyList tokens, out string } private static bool TryDetectUnsupportedInvocationShape( - IReadOnlyList tokens, out string? reason) + IReadOnlyList tokens, + PwshDialect dialect, + out string? reason) { var verbSlot = true; for (var index = 0; index < tokens.Count; index++) @@ -232,7 +268,7 @@ private static bool TryDetectUnsupportedInvocationShape( } } - if (IsUnsupportedModuleQualifiedCmdlet(token.Value)) + if (IsUnsupportedModuleQualifiedCmdlet(token.Value, dialect)) { reason = $"module-qualified cmdlet '{token.Value}' is not supported in v0.2"; return true; @@ -240,7 +276,7 @@ private static bool TryDetectUnsupportedInvocationShape( } if (verbSlot && token.Kind == PwshTokenKind.QuotedString - && IsUnsupportedModuleQualifiedCmdlet(token.Value)) + && IsUnsupportedModuleQualifiedCmdlet(token.Value, dialect)) { reason = $"module-qualified cmdlet '{token.Value}' is not supported in v0.2"; return true; @@ -253,7 +289,9 @@ private static bool TryDetectUnsupportedInvocationShape( return false; } - private static bool IsUnsupportedModuleQualifiedCmdlet(string command) + private static bool IsUnsupportedModuleQualifiedCmdlet( + string command, + PwshDialect dialect) { if (string.Equals( command, @@ -276,7 +314,7 @@ private static bool IsUnsupportedModuleQualifiedCmdlet(string command) var commandName = command.Substring(separator + 1); return PwshApprovedVerbs.IsCmdletShaped(commandName) || - PwshAliases.IsKnownCanonical(commandName); + PwshAliases.IsKnownCanonical(commandName, dialect); } private static bool TryDetectKeywordAnomaly(IReadOnlyList tokens, out string? reason) @@ -564,7 +602,7 @@ private static BuildResult BuildSegment( } // Classify the command. - var classified = ClassifyVerb(body, start); + var classified = ClassifyVerb(body, start, effectiveOptions.Dialect); if (TryHandleInvokeExpression( body, start, classified, source, baseOptions, recursionDepth, structuralDepth, segment, markWrapped, attribution, out var expressionResult)) @@ -715,7 +753,10 @@ private readonly struct ClassifiedVerb public HashSet VerbPositions { get; init; } } - private static ClassifiedVerb ClassifyVerb(List body, int start) + private static ClassifiedVerb ClassifyVerb( + List body, + int start, + PwshDialect dialect) { var head = body[start]; var verbPositions = new HashSet { start }; @@ -771,12 +812,12 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) Kind = PwshCommandKind.Cmdlet, VerbTokens = new List { word }, BindingSemanticsProven = PwshVerbs.FileVerbs.Contains(word) - || PwshAliases.IsKnownCanonical(word), + || PwshAliases.IsKnownCanonical(word, dialect), VerbPositions = verbPositions, }; } - var alias = PwshAliases.Resolve(word); + var alias = PwshAliases.Resolve(word, dialect); if (alias is not null) { return new ClassifiedVerb @@ -2210,6 +2251,7 @@ private static bool TryRecurseIntoPwsh( var childOptions = redirectOptions with { InitialStateMode = PwshInitialStateMode.Unknown, + Dialect = ChildHostDialect(verb.VerbTokens[0]), }; PwshSetLocationContext? childLocation = null; if (workingDirectoryUnknown) @@ -2344,6 +2386,13 @@ private static bool IsCommandParameter(string name) && "-command".StartsWith(name, StringComparison.OrdinalIgnoreCase); } + private static PwshDialect ChildHostDialect(string host) => + host is not null && + (string.Equals(host, "powershell", StringComparison.OrdinalIgnoreCase) || + string.Equals(host, "powershell.exe", StringComparison.OrdinalIgnoreCase)) + ? PwshDialect.WindowsPowerShell51 + : PwshDialect.PowerShell7; + private static bool IsEncodedCommandParameter(string name) { if (string.Equals(name, "-e", StringComparison.OrdinalIgnoreCase)) diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs index 412468e..fe1381e 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs @@ -327,7 +327,7 @@ private bool TryParseForEachBody( } var significant = FilterSignificant(relativeTokens); - if (TryDetectAnomaly(significant, out error)) + if (TryDetectAnomaly(significant, _options.Dialect, out error)) { body = new ShellBlockSyntax(); return false; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 751cee0..68b1153 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -364,12 +364,13 @@ internal static class PwshPersistentStateMutation { internal static bool TryGetEffect( Clause clause, + PwshDialect dialect, IReadOnlyList effectiveArguments, bool providerLocationUnknown, out bool unknownCwd) { unknownCwd = false; - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); if (verb is null) { return false; @@ -414,9 +415,10 @@ internal static bool TryGetEffect( internal static bool MayEscapeChildScope( Clause clause, + PwshDialect dialect, IReadOnlyList effectiveArguments) { - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); if (verb is null) { return true; @@ -500,10 +502,11 @@ internal static bool MayEscapeChildScope( internal static bool MayEscapeChildRunspaceProcess( Clause clause, + PwshDialect dialect, IReadOnlyList effectiveArguments, bool providerLocationUnknown) { - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); if (verb is null) { return true; @@ -536,10 +539,11 @@ internal static bool MayEscapeChildRunspaceProcess( internal static bool MayMutateAutomaticHome( Clause clause, + PwshDialect dialect, IReadOnlyList effectiveArguments, bool providerLocationUnknown) { - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); if (verb is null) { return true; @@ -635,9 +639,11 @@ private static bool IsPowerShellScriptInvocation(Clause clause) .EndsWith(".ps1", StringComparison.OrdinalIgnoreCase); } - internal static bool PreservesCommandResolution(Clause clause) + internal static bool PreservesCommandResolution( + Clause clause, + PwshDialect dialect) { - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); return verb is not null && (verb.Equals("Set-Variable", StringComparison.OrdinalIgnoreCase) || verb.Equals("New-Variable", StringComparison.OrdinalIgnoreCase) || @@ -647,6 +653,7 @@ internal static bool PreservesCommandResolution(Clause clause) internal static bool TryGetCommandResolutionMutation( Clause clause, + PwshDialect dialect, IReadOnlyList effectiveArguments, bool providerLocationUnknown, out bool invalidatesAll, @@ -654,7 +661,7 @@ internal static bool TryGetCommandResolutionMutation( { invalidatesAll = false; commandNames = Array.Empty(); - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); if (verb is null) { invalidatesAll = true; @@ -1203,20 +1210,25 @@ private static AliasParameterRole ClassifyAliasParameter(string parameter) } } - internal static bool HasVariableWritingArgument(Clause clause) + internal static bool HasVariableWritingArgument( + Clause clause, + PwshDialect dialect) { - var verb = GetCanonicalVerb(clause); + var verb = GetCanonicalVerb(clause, dialect); return verb is null || HasVariableWritingArgument(verb, clause); } - private static string? GetCanonicalVerb(Clause clause) + private static string? GetCanonicalVerb( + Clause clause, + PwshDialect dialect) { var verb = clause.Verb.CanonicalVerb ?? (clause.Verb.Tokens.Count == 0 ? null : clause.Verb.Tokens[0]); if (verb is not null && PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( verb, - out var canonicalName)) + out var canonicalName, + dialect)) { return canonicalName; } @@ -2060,6 +2072,7 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext var hasCommandResolutionMutation = PwshPersistentStateMutation.TryGetCommandResolutionMutation( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null, out var invalidatesAllCommandNames, @@ -2070,11 +2083,14 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext commandIdentityMayMutateState || PwshPersistentStateMutation.MayMutateAutomaticHome( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null); var preservesCommandResolution = !commandIdentityMayMutateState && - PwshPersistentStateMutation.PreservesCommandResolution(simple.Clause); + PwshPersistentStateMutation.PreservesCommandResolution( + simple.Clause, + _options.Dialect); if (commandIdentityMayMutateState) { // Computed command identities and explicit source mutations can @@ -2104,6 +2120,7 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext _locationStateMutationCount++; if (PwshPersistentStateMutation.TryGetEffect( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null, out var locationEffectUnknownCwd)) @@ -2111,10 +2128,12 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext var mayEscapeChildRunspaceProcess = PwshPersistentStateMutation.MayEscapeChildRunspaceProcess( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null); if (PwshPersistentStateMutation.MayEscapeChildScope( simple.Clause, + _options.Dialect, effective)) { _childScopeEscapeRiskCount++; @@ -2156,6 +2175,7 @@ flow.OnFailure is AnalysisContext failure if (PwshPersistentStateMutation.TryGetEffect( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null, out var unknownCwd)) @@ -2164,10 +2184,12 @@ flow.OnFailure is AnalysisContext failure var mayEscapeChildRunspaceProcess = PwshPersistentStateMutation.MayEscapeChildRunspaceProcess( simple.Clause, + _options.Dialect, effective, providerLocationUnknown: current.WorkingDirectory is null); if (PwshPersistentStateMutation.MayEscapeChildScope( simple.Clause, + _options.Dialect, effective)) { _childScopeEscapeRiskCount++; @@ -2247,7 +2269,8 @@ private PwshFlowResult ApplyExecutionRegionEffect( receiverInput); var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, - receiverIdentityProven); + receiverIdentityProven, + _options.Dialect); if (binding.Status == PwshExecutionRegionBindingStatus.ProvedData) { RecordExecutionRegions( @@ -2335,7 +2358,8 @@ flow.OnFailure is AnalysisContext failure if (binding.ParameterSet == PwshExecutionRegionParameterSet.NewModuleScriptBlock) { var bodyInput = PwshPersistentStateMutation.HasVariableWritingArgument( - simple.Clause) + simple.Clause, + _options.Dialect) ? receiverInput.Invalidate(unknownCwd: false) : receiverInput; return AnalyzeNewModule(regions[0], bodyInput, flow); @@ -2551,6 +2575,7 @@ private AnalysisContext CreateStartJobInput( HomeDirectory = _options.HomeDirectory, WorkingDirectory = receiverInput.WorkingDirectory, InitialStateMode = _options.InitialStateMode, + Dialect = _options.Dialect, }; var resolved = PwshResolver.Resolve( targetValue, @@ -3304,7 +3329,7 @@ private PwshFlowResult AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext } } - private static bool PipelineStageEffectsMayReachRegionBodies(PipelineSyntax pipeline) + private bool PipelineStageEffectsMayReachRegionBodies(PipelineSyntax pipeline) { var hasPipelineSensitiveRegion = false; var statefulStageCount = 0; @@ -3321,7 +3346,7 @@ private static bool PipelineStageEffectsMayReachRegionBodies(PipelineSyntax pipe return hasPipelineSensitiveRegion && statefulStageCount > 1; } - private static bool ContainsPipelineSensitiveExecutionRegion(ShellSyntaxNode node) => + private bool ContainsPipelineSensitiveExecutionRegion(ShellSyntaxNode node) => node switch { SimpleCommandSyntax simple => IsPipelineSensitiveExecutionRegionHost(simple), @@ -3339,7 +3364,7 @@ private static bool ContainsPipelineSensitiveExecutionRegion(ShellSyntaxNode nod _ => false, }; - private static bool MayMutatePipelineState(ShellSyntaxNode node) => + private bool MayMutatePipelineState(ShellSyntaxNode node) => node switch { SimpleCommandSyntax simple => SimpleMayMutatePipelineState(simple), @@ -3353,11 +3378,12 @@ private static bool MayMutatePipelineState(ShellSyntaxNode node) => _ => false, }; - private static bool IsPipelineSensitiveExecutionRegionHost(SimpleCommandSyntax simple) + private bool IsPipelineSensitiveExecutionRegionHost(SimpleCommandSyntax simple) { var binding = PwshExecutionRegionBindingCatalog.Bind( simple.Clause, - commandIdentityProven: true); + commandIdentityProven: true, + dialect: _options.Dialect); return binding.Status == PwshExecutionRegionBindingStatus.ProvedExecution && binding.ParameterSet is PwshExecutionRegionParameterSet.ForEachScriptBlock or PwshExecutionRegionParameterSet.ForEachParallel or @@ -3368,7 +3394,7 @@ PwshExecutionRegionParameterSet.TraceExpression or PwshExecutionRegionParameterSet.NewModuleScriptBlock; } - private static bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) + private bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) { if (simple.Substitutions.Count > 0 || simple.ExecutionRegions.Count > 0 || @@ -3379,6 +3405,7 @@ private static bool SimpleMayMutatePipelineState(SimpleCommandSyntax simple) return PwshPersistentStateMutation.TryGetEffect( simple.Clause, + _options.Dialect, Array.Empty(), providerLocationUnknown: false, out _); @@ -3799,6 +3826,7 @@ private bool TryConsumeLoopAnalysisTransition() HomeDirectory = _options.HomeDirectory, WorkingDirectory = input.WorkingDirectory, InitialStateMode = _options.InitialStateMode, + Dialect = _options.Dialect, }; var resolved = PwshResolver.Resolve( ShellValue.Literal(target), @@ -4391,6 +4419,7 @@ value[0] is not ('\'' or '"') || HomeDirectory = _options.HomeDirectory, WorkingDirectory = workingDirectory, InitialStateMode = _options.InitialStateMode, + Dialect = _options.Dialect, }; var resolved = PwshResolver.Resolve( ShellValue.Literal(value), diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index 0a97245..63f0409 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -502,7 +502,10 @@ private bool TryParseCommand( CollapseSafeForEachCommandArgument(segmentTokens, compatibilityOperator); - if (TryDetectUnsupportedInvocationShape(segmentTokens, out error)) + if (TryDetectUnsupportedInvocationShape( + segmentTokens, + _options.Dialect, + out error)) { return false; } @@ -579,6 +582,7 @@ private bool TryParseCommand( HomeDirectory = _options.HomeDirectory, WorkingDirectory = _attribution.ResolvedCwd, InitialStateMode = _options.InitialStateMode, + Dialect = _options.Dialect, }; } else if (_attribution.IsDynamic) @@ -712,7 +716,8 @@ private bool TryParseCommandExecutionRegions( { var binding = PwshExecutionRegionBindingCatalog.Bind( clause, - commandIdentityProven: false); + commandIdentityProven: false, + dialect: _options.Dialect); if (binding.Status == PwshExecutionRegionBindingStatus.NotApplicable) { executionRegions = Array.Empty(); @@ -818,7 +823,7 @@ private bool TryParseScriptBlockBody( } var significant = FilterSignificant(relativeTokens); - if (TryDetectAnomaly(significant, out error)) + if (TryDetectAnomaly(significant, _options.Dialect, out error)) { body = new ShellBlockSyntax(); return false; @@ -1615,7 +1620,7 @@ private bool TryParseSubstitutionBody( } var significant = FilterSignificant(relativeTokens); - if (TryDetectAnomaly(significant, out error)) + if (TryDetectAnomaly(significant, _options.Dialect, out error)) { body = new ShellBlockSyntax(); return false; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs index 517495b..3225b44 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs @@ -9,10 +9,9 @@ namespace ShellSyntaxTree.Internal.Pwsh.Verbs; /// -/// The default PowerShell built-in alias table — a case-insensitive map -/// from a typed alias to its canonical cmdlet (SPEC.POWERSHELL.md §6.3). -/// Alias resolution is unconditional: the most security-relevant -/// normalization the parser performs. +/// The default PowerShell built-in alias tables — case-insensitive maps +/// from a typed alias to its canonical command (SPEC.POWERSHELL.md §6.3). +/// Alias resolution is unconditional within the selected dialect. /// /// /// @@ -28,14 +27,15 @@ namespace ShellSyntaxTree.Internal.Pwsh.Verbs; /// — a false-negative-shaped failure (§6.3). /// /// -/// The PwshAliasCompletenessTests [Fact] diffs this table against -/// live Get-Alias and fails on any gap (a live alias absent here); -/// extra Windows-only entries are expected and allowed. +/// The PwshOracleTests alias gate diffs this table against live +/// Get-Alias, including canonical definitions, and fails on any gap; +/// extra Windows-only entries are expected and allowed for PowerShell 7. /// /// -/// curl, wget, sc, set, start, and -/// where are deliberately absent: §6.3 collision rule 3 treats them -/// as native commands, never aliased. md / mkdir map to +/// PowerShell 7 treats curl, wget, sc, set, +/// start, and where as native commands. Windows PowerShell 5.1 +/// defines aliases for those spellings, so its dialect table restores their +/// canonical cmdlets. md / mkdir map to /// New-Item — the effective cmdlet — rather than the thin /// mkdir function PowerShell's own Get-Alias reports. /// @@ -43,11 +43,12 @@ namespace ShellSyntaxTree.Internal.Pwsh.Verbs; internal static class PwshAliases { /// - /// Aliases §6.3 collision rule 3 forbids resolving — their cmdlet vs. - /// native-tool meaning is version-dependent. Absent from - /// ; the parser treats them as native commands. + /// Aliases §6.3 collision rule 3 forbids resolving in PowerShell 7 — + /// their cmdlet vs. native-tool meaning is edition-dependent. They are + /// absent from and restored only by the Windows + /// PowerShell 5.1 table. /// - internal static readonly HashSet NeverAliased = + internal static readonly HashSet PowerShell7NativeCollisions = new(StringComparer.OrdinalIgnoreCase) { "curl", "wget", "sc", "set", "start", "where", @@ -209,14 +210,84 @@ internal static class PwshAliases ["shcm"] = "Show-Command", }; + /// + /// Aliases present in Windows PowerShell 5.1 but absent from the + /// PowerShell 7 compatibility table. This includes removed Windows-only + /// commands as well as spellings that became native-command collisions. + /// + internal static readonly IReadOnlyDictionary WindowsPowerShell51Map = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["curl"] = "Invoke-WebRequest", + ["wget"] = "Invoke-WebRequest", + ["sc"] = "Set-Content", + ["set"] = "Set-Variable", + ["start"] = "Start-Process", + ["where"] = "Where-Object", + ["asnp"] = "Add-PSSnapIn", + ["epsn"] = "Export-PSSession", + ["gsnp"] = "Get-PSSnapIn", + ["gwmi"] = "Get-WmiObject", + ["ipsn"] = "Import-PSSession", + ["ise"] = "powershell_ise.exe", + ["iwmi"] = "Invoke-WmiMethod", + ["npssc"] = "New-PSSessionConfigurationFile", + ["rsnp"] = "Remove-PSSnapIn", + ["rwmi"] = "Remove-WmiObject", + ["swmi"] = "Set-WmiInstance", + ["trcm"] = "Trace-Command", + }; + + /// + /// Compatibility-table entries that Windows PowerShell 5.1 must not + /// inherit. gerr was added with Get-Error in PowerShell 7; + /// chy is a retained v0.2 compatibility spelling rather than a + /// Windows PowerShell 5.1 default alias. + /// + private static readonly HashSet WindowsPowerShell51ExcludedAliases = + new(StringComparer.OrdinalIgnoreCase) + { + "gerr", "chy", + }; + + private static readonly HashSet PowerShell7CanonicalCommands = + new(Map.Values, StringComparer.OrdinalIgnoreCase); + + private static readonly HashSet WindowsPowerShell51CanonicalCommands = + BuildWindowsPowerShell51CanonicalCommands(); + /// /// Resolve to its canonical cmdlet. Returns - /// null when the token is not a known alias or is one of the - /// commands. Case-insensitive. + /// null when the token is not a known alias in the selected dialect. + /// Case-insensitive. /// - internal static string? Resolve(string token) + internal static string? Resolve( + string token, + PwshDialect dialect) { - if (string.IsNullOrEmpty(token) || NeverAliased.Contains(token)) + if (string.IsNullOrEmpty(token)) + { + return null; + } + + if (dialect == PwshDialect.WindowsPowerShell51) + { + if (WindowsPowerShell51Map.TryGetValue(token, out var windowsCanonical)) + { + return windowsCanonical; + } + + if (WindowsPowerShell51ExcludedAliases.Contains(token)) + { + return null; + } + } + else if (dialect != PwshDialect.PowerShell7) + { + return null; + } + + if (PowerShell7NativeCollisions.Contains(token)) { return null; } @@ -224,16 +295,33 @@ internal static class PwshAliases return Map.TryGetValue(token, out var canonical) ? canonical : null; } - internal static bool IsKnownCanonical(string token) + internal static bool IsKnownCanonical( + string token, + PwshDialect dialect) + => dialect switch + { + PwshDialect.PowerShell7 => PowerShell7CanonicalCommands.Contains(token), + PwshDialect.WindowsPowerShell51 => + WindowsPowerShell51CanonicalCommands.Contains(token), + _ => false, + }; + + private static HashSet BuildWindowsPowerShell51CanonicalCommands() { - foreach (var canonical in Map.Values) + var commands = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var alias in Map) { - if (string.Equals(canonical, token, StringComparison.OrdinalIgnoreCase)) + if (!WindowsPowerShell51ExcludedAliases.Contains(alias.Key)) { - return true; + commands.Add(alias.Value); } } - return false; + foreach (var canonical in WindowsPowerShell51Map.Values) + { + commands.Add(canonical); + } + + return commands; } } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs index 26d0a0d..7cbd7e2 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshExecutionRegionBindingCatalog.cs @@ -275,7 +275,8 @@ internal static bool IsSupportedModuleQualifiedCommand(string command) internal static bool TryResolveStaticCommandName( string command, - out string? canonicalName) + out string? canonicalName, + PwshDialect dialect) { canonicalName = command; var separator = command.LastIndexOf('\\'); @@ -291,7 +292,7 @@ internal static bool TryResolveStaticCommandName( return true; } - var alias = PwshAliases.Resolve(command); + var alias = PwshAliases.Resolve(command, dialect); if (alias is not null) { canonicalName = alias; @@ -303,6 +304,7 @@ internal static bool TryResolveStaticCommandName( internal static PwshExecutionRegionBindingResult Bind( Clause clause, bool commandIdentityProven, + PwshDialect dialect, bool threadJobModuleProven = false) { var scriptBlocks = FindScriptBlocks(clause.Elements); @@ -314,7 +316,7 @@ internal static PwshExecutionRegionBindingResult Bind( }; } - if (!TryResolveCommand(clause.Verb, out var canonicalName, out var entry)) + if (!TryResolveCommand(clause.Verb, dialect, out var canonicalName, out var entry)) { return Unknown(canonicalName, scriptBlocks); } @@ -340,6 +342,15 @@ internal static PwshExecutionRegionBindingResult Bind( }; } + if (dialect == PwshDialect.WindowsPowerShell51) + { + // The 5.1 callback/job parameter catalogs are intentionally not + // inferred from the PowerShell 7 metadata table. Keep completely + // delimited bodies visible, but incomplete, until each receiver + // table is proved against the Windows PowerShell oracle. + return Ambiguous(canonicalName, entry.Receiver, scriptBlocks); + } + var arguments = BindArguments(clause.Elements, entry); return BindReceiver(canonicalName!, entry, arguments, scriptBlocks); } @@ -1519,6 +1530,7 @@ private static bool IsDelimitedScriptBlock(string value) => private static bool TryResolveCommand( VerbChain verb, + PwshDialect dialect, out string? canonicalName, out CommandEntry entry) { @@ -1529,7 +1541,7 @@ private static bool TryResolveCommand( return false; } - return TryResolveStaticCommandName(canonicalName, out canonicalName) + return TryResolveStaticCommandName(canonicalName, out canonicalName, dialect) && Commands.TryGetValue(canonicalName!, out entry!); } diff --git a/src/ShellSyntaxTree/PwshParser.cs b/src/ShellSyntaxTree/PwshParser.cs index fcddf04..06d63f8 100644 --- a/src/ShellSyntaxTree/PwshParser.cs +++ b/src/ShellSyntaxTree/PwshParser.cs @@ -26,7 +26,7 @@ public PwshParser() : this(new PwshParserOptions()) /// /// Create a parser with the supplied options. /// - /// Resolver and initial-runspace contract options. + /// Dialect, resolver, and initial-runspace contract options. public PwshParser(PwshParserOptions options) { if (options is null) diff --git a/src/ShellSyntaxTree/PwshParserOptions.cs b/src/ShellSyntaxTree/PwshParserOptions.cs index 369074b..6900c5b 100644 --- a/src/ShellSyntaxTree/PwshParserOptions.cs +++ b/src/ShellSyntaxTree/PwshParserOptions.cs @@ -21,6 +21,21 @@ public enum PwshInitialStateMode IsolatedNonInteractiveNoProfile, } +/// +/// Selects the PowerShell language edition and versioned parser metadata. +/// +public enum PwshDialect +{ + /// No supported PowerShell dialect has been selected. + Unknown, + + /// PowerShell 7.6 servicing releases from 7.6.4, executed by pwsh. + PowerShell7, + + /// Windows PowerShell 5.1, executed by powershell.exe. + WindowsPowerShell51, +} + /// /// Configuration knobs for . The resolver knobs live /// on the shared base. @@ -32,4 +47,11 @@ public sealed record PwshParserOptions : ShellParserOptions /// default leaves loop-dependent effective values unproved. /// public PwshInitialStateMode InitialStateMode { get; init; } + + /// + /// Gets the PowerShell dialect whose grammar and versioned metadata apply. + /// Existing callers retain the PowerShell 7 behavior shipped before this + /// option was added. + /// + public PwshDialect Dialect { get; init; } = PwshDialect.PowerShell7; } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index b108cf9..08ded7f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -42,12 +42,14 @@ public void Corpus_entry_parses_to_expected_ast(string shell, string fileName, C if (shell != "powershell") { Assert.Null(entry.PowerShellInitialStateMode); + Assert.Null(entry.PowerShellDialect); } var actual = CreateParser( shell, entry.BashInitialStateMode, - entry.PowerShellInitialStateMode).Parse(entry.Input); + entry.PowerShellInitialStateMode, + entry.PowerShellDialect).Parse(entry.Input); AstAssert.Equal(entry.Expected!, actual, $"{shell}/{fileName}"); AssertClauseElementInvariants(actual, $"{shell}/{fileName}"); AssertAuthoredTokenCoverage(shell, actual, $"{shell}/{fileName}"); @@ -880,7 +882,8 @@ private static void AssertClauseElementInvariants(ParsedCommand parsed, string c internal static IShellParser CreateParser( string shell, BashInitialStateMode? bashInitialStateMode = null, - PwshInitialStateMode? powerShellInitialStateMode = null) => shell switch + PwshInitialStateMode? powerShellInitialStateMode = null, + PwshDialect? powerShellDialect = null) => shell switch { "bash" => new BashParser(new BashParserOptions { @@ -894,6 +897,7 @@ internal static IShellParser CreateParser( HomeDirectory = "C:/Users/user", WorkingDirectory = "C:/work", InitialStateMode = powerShellInitialStateMode ?? PwshInitialStateMode.Unknown, + Dialect = powerShellDialect ?? PwshDialect.PowerShell7, }), _ => throw new InvalidOperationException( $"No parser is registered for corpus shell directory '{shell}'."), @@ -957,12 +961,14 @@ public sealed record CorpusEntry public PwshInitialStateMode? PowerShellInitialStateMode { get; init; } + public PwshDialect? PowerShellDialect { get; init; } + public ExpectedParsedCommand? Expected { get; init; } public string? Notes { get; init; } /// - /// Ground-truth expectation for the real-pwsh validation gate + /// Ground-truth expectation for the selected PowerShell validation gate /// (SPEC.POWERSHELL.md §13). Meaningful only when /// expected.isUnparseable is true; defaults to /// . @@ -1164,18 +1170,18 @@ public sealed record ExpectedClause } /// -/// Ground-truth expectation for the real-pwsh validation gate +/// Ground-truth expectation for the selected PowerShell validation gate /// (SPEC.POWERSHELL.md §13). Meaningful only when isUnparseable is /// true. /// public enum OracleExpectation { - /// Genuinely malformed PowerShell — real pwsh must also - /// reject it. + /// Genuinely malformed PowerShell — the selected real shell must + /// also reject it. SyntaxError, /// Valid PowerShell the parser deliberately does not model — - /// real pwsh must accept it. Also covers an over-cap input, an + /// the selected real shell must accept it. Also covers an over-cap input, an /// -EncodedCommand decode failure, or a recursion overflow. OutOfScope, } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs index 55eaccf..009ad5e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs @@ -17,23 +17,23 @@ namespace ShellSyntaxTree.Tests.Corpus; /// -/// The real-pwsh validation gate (SPEC.POWERSHELL.md §13). Feeds -/// every PowerShell corpus input to the real PowerShell parser and +/// The dialect-selected PowerShell validation gate (SPEC.POWERSHELL.md §13). +/// Feeds every PowerShell corpus input to the matching real parser and /// enforces the §13 oracle matrix: /// -/// isUnparseable=false → real pwsh reports 0 parse +/// isUnparseable=false → real PowerShell reports 0 parse /// errors (the input is valid PowerShell). /// isUnparseable=true + SyntaxError → ≥1 parse error. /// isUnparseable=true + OutOfScope → 0 parse errors /// (valid PowerShell the parser declines to model). /// -/// A developer without pwsh on PATH sees the gate skip; CI installs -/// pwsh and a workflow step fails loudly if it is absent. +/// A developer without the selected executable on PATH sees that dialect's +/// gate skip; CI verifies both supported executables where they are required. /// public class PwshOracleTests { [Fact] - public void PowerShell_corpus_inputs_are_consistent_with_real_pwsh() + public void PowerShell_corpus_inputs_are_consistent_with_selected_dialect_oracle() { var entries = LoadPowershellCorpus(); if (entries.Count == 0) @@ -41,14 +41,38 @@ public void PowerShell_corpus_inputs_are_consistent_with_real_pwsh() return; // CorpusRunnerTests asserts the corpus is present. } - if (!PwshOracle.IsAvailable()) + foreach (var dialect in new[] + { + PwshDialect.PowerShell7, + PwshDialect.WindowsPowerShell51, + }) { - Console.WriteLine("pwsh not on PATH — the §13 oracle gate is skipped locally."); + ValidateDialectCorpus( + dialect, + entries.Where(entry => + (entry.Entry.PowerShellDialect ?? PwshDialect.PowerShell7) == dialect) + .ToList()); + } + } + + private static void ValidateDialectCorpus( + PwshDialect dialect, + IReadOnlyList<(string File, CorpusEntry Entry)> entries) + { + if (entries.Count == 0) + { + return; + } + + if (!PwshOracle.IsAvailable(dialect)) + { + Console.WriteLine( + $"{dialect} compatible oracle is unavailable — its §13 gate is skipped locally."); return; } var inputs = entries.Select(e => e.Entry.Input).ToList(); - var counts = PwshOracle.CountParseErrors(inputs); + var counts = PwshOracle.CountParseErrors(inputs, dialect); Assert.NotNull(counts); var failures = new List(); @@ -62,21 +86,21 @@ public void PowerShell_corpus_inputs_are_consistent_with_real_pwsh() { if (errors != 0) { - failures.Add($"{file}: corpus marks it parseable, but real pwsh reports {errors} parse error(s)."); + failures.Add($"{file}: corpus marks it parseable, but {dialect} reports {errors} parse error(s)."); } } else if (entry.OracleExpectation == OracleExpectation.SyntaxError) { if (errors == 0) { - failures.Add($"{file}: corpus marks it SyntaxError, but real pwsh accepts it (0 errors) — use oracleExpectation 'OutOfScope'."); + failures.Add($"{file}: corpus marks it SyntaxError, but {dialect} accepts it (0 errors) — use oracleExpectation 'OutOfScope'."); } } else // OutOfScope { if (errors != 0) { - failures.Add($"{file}: corpus marks it OutOfScope, but real pwsh reports {errors} parse error(s) — it is a genuine SyntaxError."); + failures.Add($"{file}: corpus marks it OutOfScope, but {dialect} reports {errors} parse error(s) — it is a genuine SyntaxError."); } } } @@ -84,47 +108,80 @@ public void PowerShell_corpus_inputs_are_consistent_with_real_pwsh() if (failures.Count > 0) { throw new XunitException( - "The pwsh oracle gate found corpus entries inconsistent with real PowerShell:\n" + $"The {dialect} oracle gate found corpus entries inconsistent with real PowerShell:\n" + string.Join("\n", failures.Select(f => " - " + f))); } } - [Fact] - public void PwshAliases_table_covers_every_live_alias() + [Theory] + [InlineData(PwshDialect.PowerShell7)] + [InlineData(PwshDialect.WindowsPowerShell51)] + public void PwshAliases_table_covers_every_live_alias(PwshDialect dialect) { - if (!PwshOracle.IsAvailable()) + if (!PwshOracle.IsAvailable(dialect)) { - Console.WriteLine("pwsh not on PATH — the §6.3 alias completeness gate is skipped locally."); + Console.WriteLine( + $"{dialect} compatible oracle is unavailable — its §6.3 alias gate is skipped locally."); return; } - var live = PwshOracle.GetAliasNames(); + var live = PwshOracle.GetAliasDefinitions(dialect); Assert.NotNull(live); var gaps = live! - .Where(name => !PwshAliases.NeverAliased.Contains(name)) - .Where(name => !PwshAliases.Map.ContainsKey(name)) - .OrderBy(name => name, StringComparer.Ordinal) + .Where(alias => dialect != PwshDialect.PowerShell7 || + !PwshAliases.PowerShell7NativeCollisions.Contains(alias.Name)) + .Where(alias => PwshAliases.Resolve(alias.Name, dialect) is null) + .OrderBy(alias => alias.Name, StringComparer.Ordinal) + .Select(alias => alias.Name) .ToArray(); if (gaps.Length > 0) { throw new XunitException( - "PwshAliases is missing live Get-Alias entries (SPEC.POWERSHELL.md §6.3):\n " + $"PwshAliases is missing {dialect} Get-Alias entries " + + "(SPEC.POWERSHELL.md §6.3):\n " + string.Join(", ", gaps)); } + + var mismatches = live + .Where(alias => dialect != PwshDialect.PowerShell7 || + !PwshAliases.PowerShell7NativeCollisions.Contains(alias.Name)) + .Select(alias => new + { + alias.Name, + Live = NormalizeAliasDefinition(alias.Definition), + Parsed = PwshAliases.Resolve(alias.Name, dialect), + }) + .Where(alias => alias.Parsed is not null && + !string.Equals(alias.Live, alias.Parsed, StringComparison.OrdinalIgnoreCase)) + .OrderBy(alias => alias.Name, StringComparer.Ordinal) + .Select(alias => $"{alias.Name}: live={alias.Live}, parser={alias.Parsed}") + .ToArray(); + + if (mismatches.Length > 0) + { + throw new XunitException( + $"PwshAliases has incorrect {dialect} canonical definitions " + + "(SPEC.POWERSHELL.md §6.3):\n " + + string.Join("\n ", mismatches)); + } } - [Fact] - public void Foreach_binding_boundary_covers_every_fresh_host_variable() + [Theory] + [InlineData(PwshDialect.PowerShell7)] + [InlineData(PwshDialect.WindowsPowerShell51)] + public void Foreach_binding_boundary_covers_every_fresh_host_variable( + PwshDialect dialect) { - if (!PwshOracle.IsAvailable()) + if (!PwshOracle.IsAvailable(dialect)) { - Console.WriteLine("pwsh not on PATH — the foreach binding gate is skipped locally."); + Console.WriteLine( + $"{dialect} compatible oracle is unavailable — its foreach binding gate is skipped locally."); return; } - var live = PwshOracle.GetVariableNames(); + var live = PwshOracle.GetVariableNames(dialect); Assert.NotNull(live); var gaps = live! @@ -134,7 +191,7 @@ public void Foreach_binding_boundary_covers_every_fresh_host_variable() .ToArray(); Assert.True( gaps.Length == 0, - "The isolated foreach binding boundary is missing fresh-host variables: " + $"The isolated {dialect} foreach binding boundary is missing fresh-host variables: " + string.Join(", ", gaps)); } @@ -148,6 +205,23 @@ private static bool IsSimpleLoopBindingName(string name) return name.Skip(1).All(character => character == '_' || char.IsLetterOrDigit(character)); } + private static string NormalizeAliasDefinition(string definition) + { + var separator = definition.LastIndexOf('\\'); + var unqualified = separator >= 0 ? definition.Substring(separator + 1) : definition; + if (string.Equals(unqualified, "help", StringComparison.OrdinalIgnoreCase)) + { + return "Get-Help"; + } + + if (string.Equals(unqualified, "mkdir", StringComparison.OrdinalIgnoreCase)) + { + return "New-Item"; + } + + return unqualified; + } + private static List<(string File, CorpusEntry Entry)> LoadPowershellCorpus() { var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", "powershell"); diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/293_v03_pwsh_payload_stays_bash_argument.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/293_v03_pwsh_payload_stays_bash_argument.json new file mode 100644 index 0000000..24e8161 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/293_v03_pwsh_payload_stays_bash_argument.json @@ -0,0 +1,136 @@ +{ + "name": "V03 pwsh payload stays bash argument", + "input": "pwsh -NoProfile -Command \u0027Get-Content input.txt | Set-Content output.txt\u0027", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "pwsh" + ], + "args": [ + { + "raw": "-NoProfile", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "-Command", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "\u0027Get-Content input.txt | Set-Content output.txt\u0027", + "kind": "Literal", + "isPath": true, + "resolved": "/work/Get-Content input.txt | Set-Content output.txt", + "isFlag": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "pwsh", + "value": "pwsh", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "-NoProfile", + "value": "-NoProfile", + "role": "Argument", + "sourceStart": 5, + "sourceLength": 10, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": true, + "isPath": false + }, + { + "raw": "-Command", + "value": "-Command", + "role": "Argument", + "sourceStart": 16, + "sourceLength": 8, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": true, + "isPath": false + }, + { + "raw": "\u0027Get-Content input.txt | Set-Content output.txt\u0027", + "value": "Get-Content input.txt | Set-Content output.txt", + "role": "Argument", + "sourceStart": 25, + "sourceLength": 48, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": true, + "resolved": "/work/Get-Content input.txt | Set-Content output.txt" + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 73, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 73, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 73 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Bash treats pwsh as an ordinary external command and does not parse its PowerShell payload." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/492_v03_windows_powershell_pipeline_chain_syntax_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/492_v03_windows_powershell_pipeline_chain_syntax_error.json new file mode 100644 index 0000000..a09253d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/492_v03_windows_powershell_pipeline_chain_syntax_error.json @@ -0,0 +1,10 @@ +{ + "name": "V03 windows powershell pipeline chain syntax error", + "input": "Get-Item a \u0026\u0026 Get-Item b", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Windows PowerShell 5.1 rejects PowerShell 7 pipeline-chain syntax." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/493_v03_windows_powershell_curl_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/493_v03_windows_powershell_curl_alias.json new file mode 100644 index 0000000..074117c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/493_v03_windows_powershell_curl_alias.json @@ -0,0 +1,101 @@ +{ + "name": "V03 windows powershell curl alias", + "input": "curl example.test", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "curl" + ], + "canonicalVerb": "Invoke-WebRequest", + "args": [ + { + "raw": "example.test", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "curl", + "value": "curl", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "example.test", + "value": "example.test", + "role": "Argument", + "sourceStart": 5, + "sourceLength": 12, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 17, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 17, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 17 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Windows PowerShell 5.1 resolves curl to its default Invoke-WebRequest alias." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/494_v03_windows_powershell_parallel_receiver_unknown.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/494_v03_windows_powershell_parallel_receiver_unknown.json new file mode 100644 index 0000000..243436d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/494_v03_windows_powershell_parallel_receiver_unknown.json @@ -0,0 +1,212 @@ +{ + "name": "V03 windows powershell parallel receiver unknown", + "input": "ForEach-Object -Parallel { Get-Date }", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "ForEach-Object" + ], + "args": [ + { + "raw": "-Parallel", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": true + }, + { + "raw": "{ Get-Date }", + "kind": "DynamicSkip", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "ForEach-Object", + "value": "ForEach-Object", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 14, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "-Parallel", + "value": "-Parallel", + "role": "Argument", + "sourceStart": 15, + "sourceLength": 9, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": true, + "isPath": false + }, + { + "raw": "{ Get-Date }", + "value": "{ Get-Date }", + "role": "Argument", + "sourceStart": 25, + "sourceLength": 12, + "precedingVerbElementCount": 1, + "kind": "DynamicSkip", + "isFlag": false, + "isPath": false + } + ] + }, + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [], + "elements": [ + { + "raw": "Get-Date", + "value": "Get-Date", + "role": "Verb", + "sourceStart": 27, + "sourceLength": 8, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 37, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 37, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ExecutionRegion", + "parentIndex": 1, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 25, + "sourceLength": 12, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "executionOrigin": "CommandArgument", + "hostClauseElementIndex": 2, + "executionPhase": "Unknown", + "executionTiming": "Unknown", + "executionCardinality": "Unknown" + }, + { + "kind": "Block", + "parentIndex": 2, + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 26, + "sourceLength": 10, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 27, + "sourceLength": 8, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 37 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "ExecutionRegion", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 37 + }, + { + "ancestorKind": "ExecutionRegion", + "region": "ExecutionRegion", + "childIndex": 0, + "sourceStart": 25, + "sourceLength": 12 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 26, + "sourceLength": 10 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "The PowerShell 7-only Parallel receiver contract is not borrowed by Windows PowerShell 5.1." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/495_v03_windows_powershell_wmi_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/495_v03_windows_powershell_wmi_alias.json new file mode 100644 index 0000000..f84ff7b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/495_v03_windows_powershell_wmi_alias.json @@ -0,0 +1,101 @@ +{ + "name": "V03 windows powershell wmi alias", + "input": "gwmi Win32_OperatingSystem", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "gwmi" + ], + "canonicalVerb": "Get-WmiObject", + "args": [ + { + "raw": "Win32_OperatingSystem", + "kind": "Literal", + "isPath": false, + "resolved": "__NULL__", + "isFlag": false + } + ], + "redirects": [], + "elements": [ + { + "raw": "gwmi", + "value": "gwmi", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + }, + { + "raw": "Win32_OperatingSystem", + "value": "Win32_OperatingSystem", + "role": "Argument", + "sourceStart": 5, + "sourceLength": 21, + "precedingVerbElementCount": 1, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 26, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 26, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 26 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Windows PowerShell 5.1 retains the removed Get-WmiObject alias contract." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/496_v03_windows_powershell_no_get_error_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/496_v03_windows_powershell_no_get_error_alias.json new file mode 100644 index 0000000..4278f75 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/496_v03_windows_powershell_no_get_error_alias.json @@ -0,0 +1,81 @@ +{ + "name": "V03 windows powershell no get error alias", + "input": "gerr", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "gerr" + ], + "args": [], + "redirects": [], + "elements": [ + { + "raw": "gerr", + "value": "gerr", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 4, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 4, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 4, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 4 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Windows PowerShell 5.1 does not borrow PowerShell 7\u0027s Get-Error alias." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/497_v03_windows_powershell_nested_chain_foreach.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/497_v03_windows_powershell_nested_chain_foreach.json new file mode 100644 index 0000000..7517338 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/497_v03_windows_powershell_nested_chain_foreach.json @@ -0,0 +1,10 @@ +{ + "name": "V03 windows powershell nested chain foreach", + "input": "foreach ($x in 1) { Get-Item a \u0026\u0026 Get-Item b }", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a foreach body." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/498_v03_windows_powershell_nested_chain_direct_block.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/498_v03_windows_powershell_nested_chain_direct_block.json new file mode 100644 index 0000000..f8331d2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/498_v03_windows_powershell_nested_chain_direct_block.json @@ -0,0 +1,10 @@ +{ + "name": "V03 windows powershell nested chain direct block", + "input": "\u0026 { Get-Item a \u0026\u0026 Get-Item b }", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a direct script block." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/499_v03_windows_powershell_nested_chain_command_block.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/499_v03_windows_powershell_nested_chain_command_block.json new file mode 100644 index 0000000..27d8be9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/499_v03_windows_powershell_nested_chain_command_block.json @@ -0,0 +1,10 @@ +{ + "name": "V03 windows powershell nested chain command block", + "input": "ForEach-Object -Process { Get-Item a \u0026\u0026 Get-Item b }", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a command-owned block." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/500_v03_windows_powershell_nested_chain_substitution.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/500_v03_windows_powershell_nested_chain_substitution.json new file mode 100644 index 0000000..515aa12 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/500_v03_windows_powershell_nested_chain_substitution.json @@ -0,0 +1,10 @@ +{ + "name": "V03 windows powershell nested chain substitution", + "input": "$(Get-Item a \u0026\u0026 Get-Item b)", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a command substitution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/501_v03_windows_powershell_nested_chain_child_host.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/501_v03_windows_powershell_nested_chain_child_host.json new file mode 100644 index 0000000..4396132 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/501_v03_windows_powershell_nested_chain_child_host.json @@ -0,0 +1,11 @@ +{ + "name": "V03 windows powershell nested chain child host", + "input": "powershell.exe -Command \u0027Get-Item a \u0026\u0026 Get-Item b\u0027", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "A static Windows PowerShell child keeps the 5.1 grammar inside its decoded payload.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/502_v03_bash_payload_stays_powershell_argument.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/502_v03_bash_payload_stays_powershell_argument.json new file mode 100644 index 0000000..7e552d9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/502_v03_bash_payload_stays_powershell_argument.json @@ -0,0 +1,30 @@ +{ + "name": "V03 bash payload stays powershell argument", + "input": "bash -c \u0027rm target.txt\u0027", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "bash" + ], + "args": [ + { + "raw": "-c", + "kind": "Literal", + "isPath": false + }, + { + "raw": "\u0027rm target.txt\u0027", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/rm target.txt" + } + ], + "redirects": [] + } + ] + }, + "notes": "PowerShell treats bash as an ordinary external command and does not parse its Bash payload." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/503_v03_windows_powershell_nested_chain_invoke_expression.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/503_v03_windows_powershell_nested_chain_invoke_expression.json new file mode 100644 index 0000000..986a1a9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/503_v03_windows_powershell_nested_chain_invoke_expression.json @@ -0,0 +1,11 @@ +{ + "name": "V03 windows powershell nested chain invoke expression", + "input": "Invoke-Expression \u0027Get-Item a \u0026\u0026 Get-Item b\u0027", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell pipeline-chain operators require the PowerShell 7 dialect" + }, + "notes": "Static current-scope recursion keeps the selected 5.1 grammar while the outer parser sees data.", + "oracleExpectation": "OutOfScope" +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs new file mode 100644 index 0000000..25c13f3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs @@ -0,0 +1,217 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using ShellSyntaxTree.Internal.Pwsh.Verbs; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class ParserLanguageBoundaryTests +{ + [Fact] + public void Bash_does_not_parse_a_pwsh_command_payload() + { + var parsed = new BashParser().Parse( + "pwsh -NoProfile -Command 'Get-Content input.txt | Set-Content output.txt'"); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Equal("pwsh", occurrence.Clause.Verb.Joined); + Assert.Contains( + occurrence.Clause.Args, + argument => argument.Raw == "'Get-Content input.txt | Set-Content output.txt'"); + Assert.DoesNotContain(parsed.Commands, candidate => + candidate.Clause.Verb.Joined is "Get-Content" or "Set-Content"); + } + + [Fact] + public void PowerShell_does_not_parse_a_bash_command_payload() + { + var parsed = new PwshParser().Parse("bash -c 'rm target.txt'"); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Equal("bash", occurrence.Clause.Verb.Joined); + Assert.Contains(occurrence.Clause.Args, argument => argument.Raw == "'rm target.txt'"); + Assert.DoesNotContain(parsed.Commands, candidate => candidate.Clause.Verb.Joined == "rm"); + } + + [Fact] + public void PowerShell7_remains_the_default_dialect() + { + var parsed = new PwshParser().Parse("Get-Item a && Get-Item b"); + + Assert.False(parsed.IsUnparseable); + Assert.Equal(2, parsed.Commands.Count); + } + + [Theory] + [InlineData("Get-Item a && Get-Item b")] + [InlineData("Get-Item a || Get-Item b")] + public void WindowsPowerShell51_rejects_pipeline_chain_operators(string source) + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, source); + + Assert.True(parsed.IsUnparseable); + Assert.Contains("PowerShell 7 dialect", parsed.UnparseableReason); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + } + + [Theory] + [InlineData("foreach ($x in 1) { Get-Item a && Get-Item b }")] + [InlineData("& { Get-Item a && Get-Item b }")] + [InlineData("ForEach-Object -Process { Get-Item a && Get-Item b }")] + [InlineData("$(Get-Item a && Get-Item b)")] + [InlineData("$(Get-Item a || Get-Item b)")] + [InlineData("Invoke-Expression 'Get-Item a && Get-Item b'")] + [InlineData("powershell.exe -Command 'Get-Item a && Get-Item b'")] + public void WindowsPowerShell51_rejects_pipeline_chains_in_every_nested_parse_path( + string source) + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, source); + + Assert.True(parsed.IsUnparseable); + Assert.Contains("PowerShell 7 dialect", parsed.UnparseableReason); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + } + + [Fact] + public void PowerShell7_retains_pipeline_chains_inside_nested_regions() + { + var parsed = Parse( + PwshDialect.PowerShell7, + "& { Get-Item a && Get-Item b }"); + + Assert.False(parsed.IsUnparseable); + Assert.Equal(2, parsed.Commands.Count); + } + + [Theory] + [InlineData(PwshDialect.Unknown)] + [InlineData((PwshDialect)999)] + public void Unsupported_dialect_values_fail_closed(PwshDialect dialect) + { + var parsed = Parse(dialect, "Get-Date"); + + Assert.True(parsed.IsUnparseable); + Assert.Contains("unsupported PowerShell dialect", parsed.UnparseableReason); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + } + + [Fact] + public void WindowsPowerShell51_uses_its_curl_alias() + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, "curl example.test"); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Equal("curl", occurrence.Clause.Verb.Joined); + Assert.Equal("Invoke-WebRequest", occurrence.Clause.Verb.CanonicalVerb); + } + + [Fact] + public void PowerShell7_keeps_curl_native() + { + var parsed = Parse(PwshDialect.PowerShell7, "curl example.test"); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Null(occurrence.Clause.Verb.CanonicalVerb); + } + + [Theory] + [InlineData("asnp", "Add-PSSnapIn")] + [InlineData("gwmi", "Get-WmiObject")] + [InlineData("ise", "powershell_ise.exe")] + [InlineData("trcm", "Trace-Command")] + public void WindowsPowerShell51_uses_edition_specific_aliases( + string alias, + string canonical) + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, alias); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Equal(alias, occurrence.Clause.Verb.Joined); + Assert.Equal(canonical, occurrence.Clause.Verb.CanonicalVerb); + } + + [Theory] + [InlineData("gerr")] + [InlineData("chy")] + public void WindowsPowerShell51_does_not_borrow_non_51_aliases(string command) + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, command); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Null(occurrence.Clause.Verb.CanonicalVerb); + } + + [Fact] + public void Canonical_alias_targets_are_also_dialect_local() + { + Assert.True(PwshAliases.IsKnownCanonical( + "Get-WmiObject", PwshDialect.WindowsPowerShell51)); + Assert.False(PwshAliases.IsKnownCanonical( + "Get-WmiObject", PwshDialect.PowerShell7)); + Assert.True(PwshAliases.IsKnownCanonical( + "Get-Error", PwshDialect.PowerShell7)); + Assert.False(PwshAliases.IsKnownCanonical( + "Get-Error", PwshDialect.WindowsPowerShell51)); + } + + [Fact] + public void WindowsPowerShell51_dialect_survives_nested_current_scope_parsing() + { + var parsed = Parse(PwshDialect.WindowsPowerShell51, "$(curl example.test)"); + + var occurrence = Assert.Single(parsed.Commands); + Assert.Equal("Invoke-WebRequest", occurrence.Clause.Verb.CanonicalVerb); + } + + [Fact] + public void Static_pwsh_child_switches_to_PowerShell7() + { + var parsed = Parse( + PwshDialect.WindowsPowerShell51, + "pwsh -Command 'Get-Item a && Get-Item b'"); + + Assert.False(parsed.IsUnparseable); + Assert.Equal(2, parsed.Commands.Count); + Assert.All(parsed.Commands, occurrence => Assert.True(occurrence.Clause.IsCommandStringWrapped)); + } + + [Fact] + public void Static_powershell_child_switches_to_WindowsPowerShell51() + { + var parsed = Parse( + PwshDialect.PowerShell7, + "powershell.exe -Command 'Get-Item a && Get-Item b'"); + + Assert.True(parsed.IsUnparseable); + Assert.Empty(parsed.Commands); + Assert.Empty(parsed.Clauses); + } + + [Fact] + public void WindowsPowerShell51_does_not_borrow_parallel_receiver_metadata() + { + var parsed = Parse( + PwshDialect.WindowsPowerShell51, + "ForEach-Object -Parallel { Get-Date }"); + + Assert.False(parsed.IsUnparseable); + Assert.Contains(parsed.Commands, occurrence => + occurrence.Clause.Verb.Joined == "ForEach-Object" && !occurrence.IsComplete); + Assert.Contains(parsed.Commands, occurrence => + occurrence.Clause.Verb.Joined == "Get-Date" && !occurrence.IsComplete); + } + + private static ParsedCommand Parse(PwshDialect dialect, string source) => + new PwshParser(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + Dialect = dialect, + }).Parse(source); +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs index b48343f..22f55a5 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshExecutionRegionBindingCatalogTests.cs @@ -101,7 +101,8 @@ public void Module_qualified_catalog_lookup_uses_authored_identity() { var resolved = PwshExecutionRegionBindingCatalog.TryResolveStaticCommandName( "Microsoft.PowerShell.Core\\ForEach-Object", - out var canonical); + out var canonical, + PwshDialect.PowerShell7); var conservative = Parser.Parse( "Microsoft.PowerShell.Core\\ForEach-Object { Remove-Item victim.txt }"); var parsed = IsolatedParser.Parse( @@ -644,7 +645,8 @@ public void Start_job_retains_the_bound_working_directory_coordinate( var clause = ParseClause(source); var result = PwshExecutionRegionBindingCatalog.Bind( clause, - commandIdentityProven: true); + commandIdentityProven: true, + dialect: PwshDialect.PowerShell7); var elementIndex = Assert.IsType(result.WorkingDirectoryElementIndex); Assert.EndsWith(expectedValue, clause.Elements[elementIndex].Value); @@ -743,10 +745,12 @@ public void Unproved_command_identity_cannot_claim_receiver_semantics_or_data() var executing = PwshExecutionRegionBindingCatalog.Bind( executingClause, - commandIdentityProven: false); + commandIdentityProven: false, + dialect: PwshDialect.PowerShell7); var data = PwshExecutionRegionBindingCatalog.Bind( dataClause, - commandIdentityProven: false); + commandIdentityProven: false, + dialect: PwshDialect.PowerShell7); Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, executing.Status); Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, data.Status); @@ -762,10 +766,12 @@ public void Thread_job_requires_the_explicit_pinned_module_baseline() var unpinned = PwshExecutionRegionBindingCatalog.Bind( clause, commandIdentityProven: true, + dialect: PwshDialect.PowerShell7, threadJobModuleProven: false); var pinned = PwshExecutionRegionBindingCatalog.Bind( clause, commandIdentityProven: true, + dialect: PwshDialect.PowerShell7, threadJobModuleProven: true); Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, unpinned.Status); @@ -786,6 +792,7 @@ public void Pinned_thread_job_still_requires_a_main_script_or_file() var result = PwshExecutionRegionBindingCatalog.Bind( clause, commandIdentityProven: true, + dialect: PwshDialect.PowerShell7, threadJobModuleProven: true); Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); @@ -802,6 +809,7 @@ public void Pinned_thread_job_rejects_invalid_required_string_values(string sour var result = PwshExecutionRegionBindingCatalog.Bind( ParseClause(source), commandIdentityProven: true, + dialect: PwshDialect.PowerShell7, threadJobModuleProven: true); Assert.Equal(PwshExecutionRegionBindingStatus.Ambiguous, result.Status); @@ -824,7 +832,8 @@ private static PwshExecutionRegionBindingResult Bind(string source) var clause = ParseClause(source); var result = PwshExecutionRegionBindingCatalog.Bind( clause, - commandIdentityProven: true); + commandIdentityProven: true, + dialect: PwshDialect.PowerShell7); Assert.All(result.Bindings, binding => { Assert.InRange(binding.HostClauseElementIndex, 0, clause.Elements.Count - 1); diff --git a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs index cd844c0..f9e7c1d 100644 --- a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs @@ -238,12 +238,13 @@ public void PwshParserOptions_has_expected_shape() AssertInitProperty(t, "HomeDirectory", typeof(string), nullable: true); AssertInitProperty(t, "WorkingDirectory", typeof(string), nullable: true); AssertInitProperty(t, "InitialStateMode", typeof(PwshInitialStateMode)); + AssertInitProperty(t, "Dialect", typeof(PwshDialect)); var declaredProps = DeclaredInstanceProps(t) .Where(p => p.Name != "EqualityContract") .Select(p => p.Name) .ToArray(); - Assert.Equal(new[] { "InitialStateMode" }, declaredProps); + Assert.Equal(new[] { "InitialStateMode", "Dialect" }, declaredProps); } [Fact] @@ -260,6 +261,31 @@ public void PwshInitialStateMode_has_expected_values_and_safe_default() }.InitialStateMode); } + [Fact] + public void PwshDialect_has_expected_values_and_compatible_default() + { + Assert.Equal(0, (int)PwshDialect.Unknown); + Assert.Equal(1, (int)PwshDialect.PowerShell7); + Assert.Equal(2, (int)PwshDialect.WindowsPowerShell51); + Assert.Equal(PwshDialect.PowerShell7, new PwshParserOptions().Dialect); + } + + [Fact] + public void PwshDialect_participates_in_record_value_behavior() + { + var powerShell7 = new PwshParserOptions(); + var equivalent = new PwshParserOptions { Dialect = PwshDialect.PowerShell7 }; + var windowsPowerShell = new PwshParserOptions + { + Dialect = PwshDialect.WindowsPowerShell51, + }; + + Assert.Equal(powerShell7, equivalent); + Assert.Equal(powerShell7.GetHashCode(), equivalent.GetHashCode()); + Assert.NotEqual(powerShell7, windowsPowerShell); + Assert.Contains("Dialect = PowerShell7", powerShell7.ToString()); + } + // -------- ParsedCommand -------- [Fact] @@ -597,6 +623,7 @@ public void Public_namespace_contains_only_expected_types() nameof(ParsedCommand), nameof(PipelineSyntax), nameof(PwshParser), + nameof(PwshDialect), nameof(PwshInitialStateMode), nameof(PwshParserOptions), nameof(Redirect), diff --git a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs index 59967e2..6454e65 100644 --- a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs @@ -553,6 +553,7 @@ public void Unknown_numeric_enum_values_remain_detectable_for_consumer_rejection var policySensitiveEnums = new[] { typeof(BashInitialStateMode), + typeof(PwshDialect), typeof(PwshInitialStateMode), typeof(ShellSyntaxKind), typeof(ShellGroupKind), diff --git a/tools/PwshCorpusTool/CorpusJson.cs b/tools/PwshCorpusTool/CorpusJson.cs index 2f2727e..a9a1665 100644 --- a/tools/PwshCorpusTool/CorpusJson.cs +++ b/tools/PwshCorpusTool/CorpusJson.cs @@ -35,7 +35,8 @@ internal static string BuildEntry( bool includeStructure, bool includeOptionalAssertions, bool includeV03Assertions, - PwshInitialStateMode? powerShellInitialStateMode = null) + PwshInitialStateMode? powerShellInitialStateMode = null, + PwshDialect? powerShellDialect = null) { var obj = new JsonObject { @@ -48,6 +49,11 @@ internal static string BuildEntry( obj["powerShellInitialStateMode"] = initialStateMode.ToString(); } + if (powerShellDialect is PwshDialect dialect) + { + obj["powerShellDialect"] = dialect.ToString(); + } + obj["expected"] = BuildExpected( parsed, includeElements, diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 2aba587..23d0db2 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -29,7 +29,8 @@ internal sealed record ManifestEntry( bool IncludeOptionalAssertions = false, bool IncludeV03Assertions = false, string? DisplayName = null, - PwshInitialStateMode? PowerShellInitialStateMode = null) + PwshInitialStateMode? PowerShellInitialStateMode = null, + PwshDialect? PowerShellDialect = null) { /// Explicit display name when supplied; otherwise derived from the slug. public string Name => @@ -70,6 +71,23 @@ private static ManifestEntry EI(string slug, string input, string notes) => PowerShellInitialStateMode: PwshInitialStateMode.IsolatedNonInteractiveNoProfile); + private static ManifestEntry W( + string slug, + string input, + string notes, + bool outOfScope = false) => + new( + slug, + input, + notes, + outOfScope, + ManifestTransform.None, + IncludeElements: true, + IncludeStructure: true, + IncludeOptionalAssertions: true, + IncludeV03Assertions: true, + PowerShellDialect: PwshDialect.WindowsPowerShell51); + private static ManifestEntry P(string slug, string input, string notes) => new(slug, input, notes, false, ManifestTransform.None, IncludeElements: true); @@ -1361,5 +1379,44 @@ private static string NestIex(string inner, int depth) VIE("v03_iex_native_tilde_binding", "iex 'curl ~'", "A static current-scope Invoke-Expression payload preserves native binding and expands an unquoted tilde from the configured home."), + W("v03_windows_powershell_pipeline_chain_syntax_error", + "Get-Item a && Get-Item b", + "Windows PowerShell 5.1 rejects PowerShell 7 pipeline-chain syntax."), + W("v03_windows_powershell_curl_alias", + "curl example.test", + "Windows PowerShell 5.1 resolves curl to its default Invoke-WebRequest alias."), + W("v03_windows_powershell_parallel_receiver_unknown", + "ForEach-Object -Parallel { Get-Date }", + "The PowerShell 7-only Parallel receiver contract is not borrowed by Windows PowerShell 5.1.", + outOfScope: true), + W("v03_windows_powershell_wmi_alias", + "gwmi Win32_OperatingSystem", + "Windows PowerShell 5.1 retains the removed Get-WmiObject alias contract."), + W("v03_windows_powershell_no_get_error_alias", + "gerr", + "Windows PowerShell 5.1 does not borrow PowerShell 7's Get-Error alias."), + W("v03_windows_powershell_nested_chain_foreach", + "foreach ($x in 1) { Get-Item a && Get-Item b }", + "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a foreach body."), + W("v03_windows_powershell_nested_chain_direct_block", + "& { Get-Item a && Get-Item b }", + "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a direct script block."), + W("v03_windows_powershell_nested_chain_command_block", + "ForEach-Object -Process { Get-Item a && Get-Item b }", + "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a command-owned block."), + W("v03_windows_powershell_nested_chain_substitution", + "$(Get-Item a && Get-Item b)", + "Windows PowerShell 5.1 rejects pipeline-chain syntax inside a command substitution."), + W("v03_windows_powershell_nested_chain_child_host", + "powershell.exe -Command 'Get-Item a && Get-Item b'", + "A static Windows PowerShell child keeps the 5.1 grammar inside its decoded payload.", + outOfScope: true), + E("v03_bash_payload_stays_powershell_argument", + "bash -c 'rm target.txt'", + "PowerShell treats bash as an ordinary external command and does not parse its Bash payload."), + W("v03_windows_powershell_nested_chain_invoke_expression", + "Invoke-Expression 'Get-Item a && Get-Item b'", + "Static current-scope recursion keeps the selected 5.1 grammar while the outer parser sees data.", + outOfScope: true), }; } diff --git a/tools/PwshCorpusTool/Program.cs b/tools/PwshCorpusTool/Program.cs index 9644ef2..840bff1 100644 --- a/tools/PwshCorpusTool/Program.cs +++ b/tools/PwshCorpusTool/Program.cs @@ -14,21 +14,24 @@ // // generate [outputDir] Regenerate every Corpus/powershell/NNN_slug.json // from the curated CorpusManifest. -// check "" Print the parser's expected-AST JSON block for a -// command beside the real-pwsh oracle verdict. +// check [--dialect ] "" +// Print the parser's expected-AST JSON block for a +// command beside the selected PowerShell verdict. // check-bash "" Print Bash parser expectations using the same // resolver settings as the executable corpus. // The corpus runner pins these resolver knobs; generation must match. -static PwshParser CreatePwshParser(PwshInitialStateMode? initialStateMode = null) => +static PwshParser CreatePwshParser( + PwshInitialStateMode? initialStateMode = null, + PwshDialect? dialect = null) => new(new PwshParserOptions { HomeDirectory = "C:/Users/user", WorkingDirectory = "C:/work", InitialStateMode = initialStateMode ?? PwshInitialStateMode.Unknown, + Dialect = dialect ?? PwshDialect.PowerShell7, }); -var parser = CreatePwshParser(); var bashParser = new BashParser(new BashParserOptions { HomeDirectory = "/home/test", @@ -47,7 +50,7 @@ static PwshParser CreatePwshParser(PwshInitialStateMode? initialStateMode = null case "generate": return Generate(args.Length > 1 ? args[1] : DefaultCorpusDir()); case "check": - return Check(string.Join(' ', args.Skip(1))); + return Check(args.Skip(1).ToArray()); case "check-bash": return CheckBash(string.Join(' ', args.Skip(1))); default: @@ -71,7 +74,9 @@ int Generate(string outputDir) foreach (var entry in entries) { var input = entry.ResolveInput(); - var parsed = CreatePwshParser(entry.PowerShellInitialStateMode).Parse(input); + var parsed = CreatePwshParser( + entry.PowerShellInitialStateMode, + entry.PowerShellDialect).Parse(input); var json = CorpusJson.BuildEntry( entry.Name, input, @@ -82,7 +87,8 @@ int Generate(string outputDir) entry.IncludeStructure, entry.IncludeOptionalAssertions, entry.IncludeV03Assertions, - entry.PowerShellInitialStateMode); + entry.PowerShellInitialStateMode, + entry.PowerShellDialect); var fileName = $"{index:D3}_{entry.Slug}.json"; File.WriteAllText(Path.Combine(outputDir, fileName), json); index++; @@ -92,15 +98,33 @@ int Generate(string outputDir) return 0; } -int Check(string command) +int Check(string[] checkArgs) { + var dialect = PwshDialect.PowerShell7; + var commandStart = 0; + if (checkArgs.Length > 0 && + string.Equals(checkArgs[0], "--dialect", StringComparison.OrdinalIgnoreCase)) + { + if (checkArgs.Length < 3 || + !Enum.TryParse(checkArgs[1], ignoreCase: true, out dialect) || + dialect is not (PwshDialect.PowerShell7 or PwshDialect.WindowsPowerShell51)) + { + Console.Error.WriteLine( + "check: --dialect must be PowerShell7 or WindowsPowerShell51."); + return 1; + } + + commandStart = 2; + } + + var command = string.Join(' ', checkArgs.Skip(commandStart)); if (string.IsNullOrEmpty(command)) { Console.Error.WriteLine("check: supply a command string."); return 1; } - var parsed = parser.Parse(command); + var parsed = CreatePwshParser(dialect: dialect).Parse(command); Console.WriteLine("---- parser expected AST ----"); Console.WriteLine(CorpusJson.BuildEntry( "check", @@ -111,20 +135,21 @@ int Check(string command) includeElements: true, includeStructure: true, includeOptionalAssertions: true, - includeV03Assertions: false)); + includeV03Assertions: false, + powerShellDialect: dialect == PwshDialect.PowerShell7 ? null : dialect)); - Console.WriteLine("---- real pwsh oracle ----"); - var counts = PwshOracle.CountParseErrors(new[] { command }); + Console.WriteLine($"---- {dialect} oracle ----"); + var counts = PwshOracle.CountParseErrors(new[] { command }, dialect); if (counts is null) { - Console.WriteLine("pwsh not available — oracle skipped."); + Console.WriteLine($"{dialect} compatible executable not available — oracle skipped."); } else { var errors = counts[0]; Console.WriteLine(errors == 0 - ? "pwsh: 0 parse errors (valid PowerShell)." - : $"pwsh: {errors} parse error(s) (malformed PowerShell)."); + ? $"{dialect}: 0 parse errors (valid PowerShell)." + : $"{dialect}: {errors} parse error(s) (malformed PowerShell)."); } Console.WriteLine($"parser: IsUnparseable={parsed.IsUnparseable}" @@ -162,6 +187,7 @@ static void PrintUsage() Console.WriteLine("PwshCorpusTool — PowerShell corpus authoring aid"); Console.WriteLine(); Console.WriteLine(" generate [outputDir] Regenerate the Corpus/powershell/ entries."); - Console.WriteLine(" check \"\" Show the parser AST + the real-pwsh verdict."); + Console.WriteLine(" check [--dialect PowerShell7|WindowsPowerShell51] \"\""); + Console.WriteLine(" Show the parser AST + selected-shell verdict."); Console.WriteLine(" check-bash \"\" Show the Bash parser AST for corpus authoring."); } diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index ea462d1..4b81d1d 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -17,8 +17,9 @@ namespace ShellSyntaxTree.Tools.PwshCorpus; /// Ground-truth validation oracle (SPEC.POWERSHELL.md §13): feeds command /// strings to the real PowerShell parser /// ([System.Management.Automation.Language.Parser]::ParseInput) via a -/// batched child-process pwsh invocation and reports the parse-error -/// count for each. Both the corpus tool's check mode and the +/// batched child-process invocation of the dialect-selected executable and +/// reports the parse-error count for each. Both the corpus tool's +/// check mode and the /// PwshOracleTests CI gate consume this. /// public static class PwshOracle @@ -27,7 +28,7 @@ public static class PwshOracle // emits a JSON array of parse-error counts. private const string OracleScript = @" param([string]$Path) -$inputs = Get-Content -Raw -LiteralPath $Path | ConvertFrom-Json +$inputs = Get-Content -Raw -Encoding UTF8 -LiteralPath $Path | ConvertFrom-Json $counts = foreach ($s in @($inputs)) { $errs = $null $toks = $null @@ -37,17 +38,34 @@ public static class PwshOracle ,@($counts) | ConvertTo-Json -Compress "; - /// True when a pwsh executable is on PATH. - public static bool IsAvailable() => - TryRunPwsh("-NoProfile -NoLogo -Command \"exit 0\"", 15000, out _); + /// + /// True when the selected executable is on PATH and its version matches + /// the requested dialect contract. + /// + public static bool IsAvailable(PwshDialect dialect) + { + var probe = VersionProbeArguments(dialect); + return probe is not null && TryRunPowerShell( + Executable(dialect), + probe, + 15000, + out _); + } /// - /// Parse-error count from real pwsh for each input — 0 means the - /// input is valid PowerShell. Returns null when pwsh is not - /// available. + /// Parse-error count from the selected real PowerShell for each input — + /// 0 means the input is valid PowerShell. Returns null when the selected + /// executable is absent or does not satisfy the dialect's version contract. /// - public static IReadOnlyList? CountParseErrors(IReadOnlyList inputs) + public static IReadOnlyList? CountParseErrors( + IReadOnlyList inputs, + PwshDialect dialect) { + if (!IsAvailable(dialect)) + { + return null; + } + if (inputs.Count == 0) { return Array.Empty(); @@ -60,7 +78,11 @@ public static bool IsAvailable() => File.WriteAllText(scriptPath, OracleScript); File.WriteAllText(inputPath, JsonSerializer.Serialize(inputs)); - if (!TryRunPwsh($"-NoProfile -NoLogo -File \"{scriptPath}\" \"{inputPath}\"", 120000, out var stdout)) + if (!TryRunPowerShell( + Executable(dialect), + $"-NoProfile -NoLogo -NonInteractive -File \"{scriptPath}\" \"{inputPath}\"", + 120000, + out var stdout)) { return null; } @@ -69,7 +91,8 @@ public static bool IsAvailable() => if (counts is null || counts.Length != inputs.Count) { throw new InvalidOperationException( - $"pwsh oracle returned {counts?.Length ?? -1} counts for {inputs.Count} inputs. Output: {stdout}"); + $"{Executable(dialect)} oracle returned {counts?.Length ?? -1} counts " + + $"for {inputs.Count} inputs. Output: {stdout}"); } return counts; @@ -86,30 +109,44 @@ public static bool IsAvailable() => } /// - /// The names of every alias the running pwsh defines — the live - /// Get-Alias set, for the PwshAliases completeness gate - /// (SPEC.POWERSHELL.md §6.3). Returns null when pwsh is absent. + /// Every alias and definition the selected PowerShell defines — the live + /// Get-Alias set for the PwshAliases completeness and + /// definition gate (SPEC.POWERSHELL.md §6.3). Returns null when the + /// selected executable is absent or incompatible. /// - public static IReadOnlyList? GetAliasNames() + public static IReadOnlyList? GetAliasDefinitions( + PwshDialect dialect) { - if (!TryRunPwsh( - "-NoProfile -NoLogo -Command \"Get-Alias | ForEach-Object Name\"", 60000, out var stdout)) + if (!IsAvailable(dialect) || + !TryRunPowerShell( + Executable(dialect), + "-NoProfile -NoLogo -NonInteractive -Command \"@(Get-Alias | Select-Object Name,Definition) | ConvertTo-Json -Compress\"", + 60000, + out var stdout)) { return null; } - return stdout.Split( - new[] { '\r', '\n' }, - StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + try + { + return JsonSerializer.Deserialize(stdout.Trim()); + } + catch (JsonException) + { + return null; + } } /// /// The names of every variable a fresh no-profile pwsh process - /// defines. Returns null when pwsh is absent. + /// defines. Returns null when the selected executable is absent or incompatible. /// - public static IReadOnlyList? GetVariableNames() + public static IReadOnlyList? GetVariableNames( + PwshDialect dialect) { - if (!TryRunPwsh( + if (!IsAvailable(dialect) || + !TryRunPowerShell( + Executable(dialect), "-NoProfile -NoLogo -NonInteractive -Command \"Get-Variable | ForEach-Object Name\"", 60000, out var stdout)) @@ -123,19 +160,28 @@ public static bool IsAvailable() => } /// - /// Run pwsh with and capture stdout. - /// Returns false when pwsh is absent, the run times out, or it + /// Run the selected executable with and capture stdout. + /// Returns false when the executable is absent, the run times out, or it /// exits non-zero. stderr is drained concurrently so a chatty child can /// never deadlock on a full pipe buffer; a timed-out child is killed. /// - private static bool TryRunPwsh(string arguments, int timeoutMs, out string stdout) + private static bool TryRunPowerShell( + string? executable, + string arguments, + int timeoutMs, + out string stdout) { stdout = string.Empty; + if (executable is null) + { + return false; + } + try { using var process = Process.Start(new ProcessStartInfo { - FileName = "pwsh", + FileName = executable, Arguments = arguments, RedirectStandardOutput = true, RedirectStandardError = true, @@ -148,25 +194,56 @@ private static bool TryRunPwsh(string arguments, int timeoutMs, out string stdou return false; } - // Start draining stderr before the blocking stdout read. - Task stderr = process.StandardError.ReadToEndAsync(); - stdout = process.StandardOutput.ReadToEnd(); + Task stdoutTask = process.StandardOutput.ReadToEndAsync(); + Task stderrTask = process.StandardError.ReadToEndAsync(); if (!process.WaitForExit(timeoutMs)) { _ = TryKill(process); + _ = process.WaitForExit(5000); + _ = Task.WaitAll(new Task[] { stdoutTask, stderrTask }, 5000); return false; } - stderr.Wait(5000); + if (!Task.WaitAll(new Task[] { stdoutTask, stderrTask }, 5000)) + { + _ = TryKill(process); + return false; + } + + stdout = stdoutTask.GetAwaiter().GetResult(); + _ = stderrTask.GetAwaiter().GetResult(); return process.ExitCode == 0; } - catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException) + catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or + FileNotFoundException or + IOException or + AggregateException) { return false; } } + private static string? VersionProbeArguments(PwshDialect dialect) => dialect switch + { + PwshDialect.PowerShell7 => + "-NoProfile -NoLogo -NonInteractive -Command \"" + + "$v = $PSVersionTable.PSVersion; " + + "if ($v -lt [version]'7.6.4' -or $v -ge [version]'7.7') { exit 2 }; exit 0\"", + PwshDialect.WindowsPowerShell51 => + "-NoProfile -NoLogo -NonInteractive -Command \"" + + "$v = $PSVersionTable.PSVersion; " + + "if ($v.Major -ne 5 -or $v.Minor -ne 1) { exit 2 }; exit 0\"", + _ => null, + }; + + private static string? Executable(PwshDialect dialect) => dialect switch + { + PwshDialect.PowerShell7 => "pwsh", + PwshDialect.WindowsPowerShell51 => "powershell.exe", + _ => null, + }; + private static bool TryKill(Process process) { try @@ -197,3 +274,6 @@ private static bool TryDelete(string path) } } } + +/// An alias name and its canonical definition from real PowerShell. +public sealed record PwshAliasDefinition(string Name, string Definition); From 250e2e3a00ad223108e47cb54480b71799bfa229 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:29:58 +0000 Subject: [PATCH 02/10] Pin the PowerShell CI oracle host --- .github/workflows/pr_validation.yml | 35 ++++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 7fe4964..b7c682b 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -19,7 +19,13 @@ jobs: strategy: matrix: - os: [ubuntu-latest, windows-latest] + include: + - os: ubuntu-latest + pwsh_asset: powershell-7.6.4-linux-x64-fxdependent.tar.gz + pwsh_sha256: e5c58d325a52200c37e6161b52e12141eb9a9d2685e2c8835c95018a614fd286 + - os: windows-latest + pwsh_asset: PowerShell-7.6.4-win-fxdependent.zip + pwsh_sha256: 2a4036b4a0c4d1d69ed9069fa97deaf4a8cd81a0eacd1a30e6e4109fdc359796 steps: - name: "Checkout" @@ -36,6 +42,33 @@ jobs: - name: "Restore .NET tools" run: dotnet tool restore + - name: "Install pinned PowerShell 7.6.4 oracle" + shell: pwsh + run: | + $asset = '${{ matrix.pwsh_asset }}' + $archive = Join-Path $env:RUNNER_TEMP $asset + $installDirectory = Join-Path $env:RUNNER_TEMP 'pwsh-7.6.4' + $uri = "https://github.com/PowerShell/PowerShell/releases/download/v7.6.4/$asset" + + Invoke-WebRequest -Uri $uri -OutFile $archive + $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() + if ($actualHash -ne '${{ matrix.pwsh_sha256 }}') { + throw "PowerShell archive hash mismatch for $asset: $actualHash" + } + + [void](New-Item -ItemType Directory -Force -Path $installDirectory) + if ($asset.EndsWith('.zip', [StringComparison]::OrdinalIgnoreCase)) { + Expand-Archive -LiteralPath $archive -DestinationPath $installDirectory -Force + } + else { + & tar -xzf $archive -C $installDirectory + if ($LASTEXITCODE -ne 0) { + throw "tar failed with exit code $LASTEXITCODE" + } + } + + $installDirectory | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + - name: "Verify copyright headers" shell: pwsh run: ./scripts/Add-FileHeaders.ps1 -Verify From 6e223d54e8b5e5d0de2b60f734f08929495673c6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:31:18 +0000 Subject: [PATCH 03/10] Fix PowerShell CI error interpolation --- .github/workflows/pr_validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index b7c682b..4c1c48e 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -53,7 +53,7 @@ jobs: Invoke-WebRequest -Uri $uri -OutFile $archive $actualHash = (Get-FileHash -Algorithm SHA256 -LiteralPath $archive).Hash.ToLowerInvariant() if ($actualHash -ne '${{ matrix.pwsh_sha256 }}') { - throw "PowerShell archive hash mismatch for $asset: $actualHash" + throw "PowerShell archive hash mismatch for ${asset}: $actualHash" } [void](New-Item -ItemType Directory -Force -Path $installDirectory) From 4535f5caa4e97e35dddbfa77a1f8f418c833bbc9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:33:24 +0000 Subject: [PATCH 04/10] Allow pinned PowerShell to run header verification --- .github/workflows/pr_validation.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 4c1c48e..22e62f7 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -70,7 +70,7 @@ jobs: $installDirectory | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append - name: "Verify copyright headers" - shell: pwsh + shell: pwsh -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -Command ". '{0}'" run: ./scripts/Add-FileHeaders.ps1 -Verify - name: "Verify compatible PowerShell 7.6 is available (SPEC.POWERSHELL.md §13 oracle gate)" From 5e637a989275a6e80f383168f688976852f101b9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:35:56 +0000 Subject: [PATCH 05/10] Run CI commands through Bash consistently --- .github/workflows/pr_validation.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/pr_validation.yml b/.github/workflows/pr_validation.yml index 22e62f7..8f4b878 100644 --- a/.github/workflows/pr_validation.yml +++ b/.github/workflows/pr_validation.yml @@ -17,6 +17,10 @@ jobs: name: Test-${{matrix.os}} runs-on: ${{matrix.os}} + defaults: + run: + shell: bash + strategy: matrix: include: From 614738b91b70ba7f6064bcbd8aebee3a036bd5e7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:42:31 +0000 Subject: [PATCH 06/10] Correct Windows PowerShell oracle coverage --- SPEC.POWERSHELL.md | 5 +- .../Internal/Pwsh/Verbs/PwshAliases.cs | 1 + ..._powershell_convert_from_string_alias.json | 82 +++++++++++++++++++ .../Parsing/ParserLanguageBoundaryTests.cs | 1 + tools/PwshCorpusTool/CorpusManifest.cs | 3 + tools/PwshCorpusTool/PwshOracle.cs | 6 +- 6 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/504_v03_windows_powershell_convert_from_string_alias.json diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 53f678b..85c5fc3 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -950,8 +950,9 @@ cwd, and code-execution verbs), not the whole table: `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 `gwmi` → `Get-WmiObject`, `asnp` → `Add-PSSnapIn`, -and `trcm` → `Trace-Command` remain edition-specific. `md` and `man` are +5.1-only aliases such as `CFS` → `ConvertFrom-String`, `gwmi` → +`Get-WmiObject`, `asnp` → `Add-PSSnapIn`, and `trcm` → `Trace-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. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs index 3225b44..aff26b6 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshAliases.cs @@ -218,6 +218,7 @@ internal static class PwshAliases internal static readonly IReadOnlyDictionary WindowsPowerShell51Map = new Dictionary(StringComparer.OrdinalIgnoreCase) { + ["cfs"] = "ConvertFrom-String", ["curl"] = "Invoke-WebRequest", ["wget"] = "Invoke-WebRequest", ["sc"] = "Set-Content", diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/504_v03_windows_powershell_convert_from_string_alias.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/504_v03_windows_powershell_convert_from_string_alias.json new file mode 100644 index 0000000..427f779 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/504_v03_windows_powershell_convert_from_string_alias.json @@ -0,0 +1,82 @@ +{ + "name": "V03 windows powershell convert from string alias", + "input": "CFS", + "powerShellDialect": "WindowsPowerShell51", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "CFS" + ], + "canonicalVerb": "ConvertFrom-String", + "args": [], + "redirects": [], + "elements": [ + { + "raw": "CFS", + "value": "CFS", + "role": "Verb", + "sourceStart": 0, + "sourceLength": 3, + "precedingVerbElementCount": 0, + "kind": "Literal", + "isFlag": false, + "isPath": false + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 3, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 3, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 3 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + } + ] + }, + "notes": "Windows PowerShell 5.1 defines CFS as the ConvertFrom-String alias." +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs index 25c13f3..3ee3d6b 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/ParserLanguageBoundaryTests.cs @@ -122,6 +122,7 @@ public void PowerShell7_keeps_curl_native() [Theory] [InlineData("asnp", "Add-PSSnapIn")] + [InlineData("cfs", "ConvertFrom-String")] [InlineData("gwmi", "Get-WmiObject")] [InlineData("ise", "powershell_ise.exe")] [InlineData("trcm", "Trace-Command")] diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 23d0db2..eae37f2 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -1418,5 +1418,8 @@ private static string NestIex(string inner, int depth) "Invoke-Expression 'Get-Item a && Get-Item b'", "Static current-scope recursion keeps the selected 5.1 grammar while the outer parser sees data.", outOfScope: true), + W("v03_windows_powershell_convert_from_string_alias", + "CFS", + "Windows PowerShell 5.1 defines CFS as the ConvertFrom-String alias."), }; } diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index 4b81d1d..de81e23 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -78,9 +78,13 @@ public static bool IsAvailable(PwshDialect dialect) File.WriteAllText(scriptPath, OracleScript); File.WriteAllText(inputPath, JsonSerializer.Serialize(inputs)); + var executionPolicy = dialect == PwshDialect.WindowsPowerShell51 + ? "-ExecutionPolicy Bypass " + : string.Empty; if (!TryRunPowerShell( Executable(dialect), - $"-NoProfile -NoLogo -NonInteractive -File \"{scriptPath}\" \"{inputPath}\"", + $"-NoProfile -NoLogo -NonInteractive {executionPolicy}" + + $"-File \"{scriptPath}\" \"{inputPath}\"", 120000, out var stdout)) { From 76ca2a7f26c9c01b399725c361faa1fb722342f3 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:48:17 +0000 Subject: [PATCH 07/10] Make PowerShell oracle failures diagnostic --- tools/PwshCorpusTool/PwshOracle.cs | 41 +++++++++++++++++++++++------- 1 file changed, 32 insertions(+), 9 deletions(-) diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index de81e23..bbc28b2 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -49,6 +49,7 @@ public static bool IsAvailable(PwshDialect dialect) Executable(dialect), probe, 15000, + out _, out _); } @@ -56,6 +57,8 @@ public static bool IsAvailable(PwshDialect dialect) /// Parse-error count from the selected real PowerShell for each input — /// 0 means the input is valid PowerShell. Returns null when the selected /// executable is absent or does not satisfy the dialect's version contract. + /// Throws when a compatible executable starts but the oracle invocation or + /// response fails. /// public static IReadOnlyList? CountParseErrors( IReadOnlyList inputs, @@ -73,6 +76,7 @@ public static bool IsAvailable(PwshDialect dialect) var scriptPath = Path.Combine(Path.GetTempPath(), $"sst-oracle-{Guid.NewGuid():N}.ps1"); var inputPath = Path.Combine(Path.GetTempPath(), $"sst-oracle-{Guid.NewGuid():N}.json"); + var stdout = string.Empty; try { File.WriteAllText(scriptPath, OracleScript); @@ -86,9 +90,11 @@ public static bool IsAvailable(PwshDialect dialect) $"-NoProfile -NoLogo -NonInteractive {executionPolicy}" + $"-File \"{scriptPath}\" \"{inputPath}\"", 120000, - out var stdout)) + out stdout, + out var failure)) { - return null; + throw new InvalidOperationException( + $"{Executable(dialect)} parse oracle failed: {failure}"); } var counts = JsonSerializer.Deserialize(stdout.Trim()); @@ -101,9 +107,11 @@ public static bool IsAvailable(PwshDialect dialect) return counts; } - catch (JsonException) + catch (JsonException ex) { - return null; + throw new InvalidOperationException( + $"{Executable(dialect)} parse oracle returned invalid JSON. Output: {stdout}", + ex); } finally { @@ -126,7 +134,8 @@ public static bool IsAvailable(PwshDialect dialect) Executable(dialect), "-NoProfile -NoLogo -NonInteractive -Command \"@(Get-Alias | Select-Object Name,Definition) | ConvertTo-Json -Compress\"", 60000, - out var stdout)) + out var stdout, + out _)) { return null; } @@ -153,7 +162,8 @@ public static bool IsAvailable(PwshDialect dialect) Executable(dialect), "-NoProfile -NoLogo -NonInteractive -Command \"Get-Variable | ForEach-Object Name\"", 60000, - out var stdout)) + out var stdout, + out _)) { return null; } @@ -173,11 +183,14 @@ private static bool TryRunPowerShell( string? executable, string arguments, int timeoutMs, - out string stdout) + out string stdout, + out string failure) { stdout = string.Empty; + failure = string.Empty; if (executable is null) { + failure = "executable is not selected"; return false; } @@ -195,6 +208,7 @@ private static bool TryRunPowerShell( if (process is null) { + failure = "process did not start"; return false; } @@ -206,24 +220,33 @@ private static bool TryRunPowerShell( _ = TryKill(process); _ = process.WaitForExit(5000); _ = Task.WaitAll(new Task[] { stdoutTask, stderrTask }, 5000); + failure = $"timed out after {timeoutMs} ms"; return false; } if (!Task.WaitAll(new Task[] { stdoutTask, stderrTask }, 5000)) { _ = TryKill(process); + failure = "stdout or stderr did not finish draining"; return false; } stdout = stdoutTask.GetAwaiter().GetResult(); - _ = stderrTask.GetAwaiter().GetResult(); - return process.ExitCode == 0; + var stderr = stderrTask.GetAwaiter().GetResult(); + if (process.ExitCode == 0) + { + return true; + } + + failure = $"exited with code {process.ExitCode}; stderr: {stderr.Trim()}"; + return false; } catch (Exception ex) when (ex is System.ComponentModel.Win32Exception or FileNotFoundException or IOException or AggregateException) { + failure = ex.Message; return false; } } From f16bdf6b23b6e339b0e3bfe8af1e0753c3447e1e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:52:02 +0000 Subject: [PATCH 08/10] Allow the pinned PowerShell oracle script --- tools/PwshCorpusTool/PwshOracle.cs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index bbc28b2..2931bc0 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -82,12 +82,9 @@ public static bool IsAvailable(PwshDialect dialect) File.WriteAllText(scriptPath, OracleScript); File.WriteAllText(inputPath, JsonSerializer.Serialize(inputs)); - var executionPolicy = dialect == PwshDialect.WindowsPowerShell51 - ? "-ExecutionPolicy Bypass " - : string.Empty; if (!TryRunPowerShell( Executable(dialect), - $"-NoProfile -NoLogo -NonInteractive {executionPolicy}" + + "-NoProfile -NoLogo -NonInteractive -ExecutionPolicy Bypass " + $"-File \"{scriptPath}\" \"{inputPath}\"", 120000, out stdout, From 3b9249e9a81cedda4b9bb90d2e7cef638a4d3ae7 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 04:56:59 +0000 Subject: [PATCH 09/10] Emit dialect-neutral oracle JSON --- tools/PwshCorpusTool/PwshOracle.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index 2931bc0..e4310df 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -35,7 +35,10 @@ public static class PwshOracle [void][System.Management.Automation.Language.Parser]::ParseInput([string]$s, [ref]$toks, [ref]$errs) $errs.Count } -,@($counts) | ConvertTo-Json -Compress +# Windows PowerShell 5.1's pipeline binder can adapt the nested array into a +# { value, Count } object here. The values are parser-produced integers, so +# emitting the JSON array directly is both dialect-neutral and unambiguous. +[Console]::Out.Write('[' + (($counts | ForEach-Object { [string]$_ }) -join ',') + ']') "; /// From d98a6aa6ba006ef27d629e0d32263fc5273059ef Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 10 Aug 2026 05:01:04 +0000 Subject: [PATCH 10/10] Record Windows dialect oracle proof --- IMPLEMENTATION_PLAN.md | 5 +++-- openspec/changes/v0-3-structured-shell-analysis/tasks.md | 9 +++++++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 37eacac..130504f 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -23,8 +23,9 @@ priorities. `PwshDialect` option with PowerShell 7 as the compatibility default and Windows PowerShell 5.1 as an explicit native-Windows fallback. Dialect- local syntax/catalog behavior and paired direct/corpus coverage are - implemented. Windows CI must still prove both live oracles before this - slice merges. + implemented. GitHub Actions run 31357084413 proved the hash-pinned + PowerShell 7.6.4 oracle on Ubuntu and Windows, plus native Windows + PowerShell 5.1 discovery and its dialect-routed oracle on Windows. - [ ] **v0.3 native-Windows Netclaw integration.** Pass the exact selected shell through Netclaw's executor, approval policy, and model context; diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index d7b7071..f4f3690 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -283,7 +283,7 @@ execution-receiver and parameter metadata only after the matching `powershell.exe` oracle proves it. The stable v0.3 catalog remains deliberately conservative, so this optional expansion does not gate 7.8. -- [ ] 7.9 Add direct and executable-corpus coverage for both parser boundaries +- [x] 7.9 Add direct and executable-corpus coverage for both parser boundaries and both PowerShell dialects. Validate PowerShell 7 cases with `pwsh`, Windows PowerShell 5.1 cases with `powershell.exe` on Windows CI, and keep unsupported or unavailable oracle states explicit rather than silently @@ -291,9 +291,14 @@ - [x] 7.9a Pin public record behavior, both language boundaries, dialect propagation and switching, 5.1 aliases, pipeline-chain rejection, and conservative receiver behavior in direct tests and dialect-routed corpus. - - [ ] 7.9b Prove the dialect-selected corpus and alias oracles on Windows CI, + - [x] 7.9b Prove the dialect-selected corpus and alias oracles on Windows CI, including `powershell.exe` discovery from the Bash environment that runs `dotnet test`. + - GitHub Actions run + [31357084413](https://github.com/Aaronontheweb/ShellSyntaxTree/actions/runs/31357084413) + passed on Ubuntu and Windows. Both jobs used the hash-pinned PowerShell + 7.6.4 oracle; the Windows job additionally discovered native Windows + PowerShell 5.1 from Bash and passed all 2,815 tests plus package creation. - [ ] 7.10 Migrate Netclaw's native Windows environment to prefer a compatible `pwsh.exe`, fall back to `powershell.exe`, and carry one canonical platform, executable, and dialect identity through LLM context, parser, approval