diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index bfc4fc0..e11e97c 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -318,8 +318,32 @@ priorities. they join multi-digit sources. Exact file targets now complete their containing occurrence, while cwd or value uncertainty still downgrades the redirect and occurrence after abstract-state joins. Direct lexer and - parser tests plus executable corpus cases pin the boundary. Next map the - PowerShell stream model, then prove the paired Netclaw redirect matrix. + parser tests plus executable corpus cases pin the boundary. Next prove + the paired Netclaw redirect matrix. +- [x] Deliver occurrence-level PowerShell explicit redirect facts while + preserving the v0.2 compatibility projection. File output and append + retain default, numbered, or all-streams sources; the native-only merge + grammar preserves sources `2`–`6` or `*` and target descriptor `1`. + File targets remain complete when their value domain is Unknown, and an + isolated bounded `foreach` can promote redirect targets to exact or + finite absolute-path domains without publishing them as command + arguments. Live PowerShell 7.6.4 oracle cases correct two obsolete + grammar assumptions: `<` is reserved, and `1>&1`, `2>&3`, and `2>&-` + are syntax errors. `$null` remains explicit but incomplete because the + locked public operation vocabulary has no discard-sink member. Direct + tests and the generated executable corpus pin static, dynamic, + malformed, multiple, all-streams, merge, suffix-boundary, and loop-bound + cases. Adversarial review additionally forced outer wrapper redirect + provenance through cwd success/failure joins, native duplicate-source + rejection, identical `$null` / `${null}` sink handling, and removal of + stale parse-time cwd targets from unreachable relative redirects while + retaining cwd-independent absolute targets. A follow-up review also + separated parent-owned outer wrapper redirect provenance from decoded + child scope, preserving finite parent-loop targets without relaxing the + child command's independent completeness. A fourth review extended that + ownership through nested quoted and encoded child hosts: the outer + redirect now uses the outermost authoring invocation rather than the + nearest decoded child. Next prove the paired Netclaw redirect matrix. - [ ] Complete PowerShell `foreach` integration and add the Netclaw approval-matrix cases. The structural slice now preserves literal scalar/array and executable iterator forms, recursively parses bodies, diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 730a707..0903cce 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -295,9 +295,9 @@ value := word | quoted_string | here_string | hash_literal // @{ ... } -> DynamicSkip Arg | splat // @var -> DynamicSkip Arg redirect := redirect_op target -redirect_op := ">" | ">>" | "<" +redirect_op := ">" | ">>" | STREAM ">" | STREAM ">>" // STREAM in {1..6, *} - | STREAM ">&" STREAM // stream merge (2>&1) + | MERGE_SOURCE ">&1" // MERGE_SOURCE in {2..6, *} target := word | quoted_string | supported_subexpression | "$null" supported_subexpression := "$(" command ")" dynamic_command_name := variable | quoted_string | supported_subexpression @@ -616,9 +616,17 @@ compatibility attribution so an unreachable body cannot leak a parse-time location, and a possibly reached mutation cannot leave a false exact path. Outcome projection also rebases cwd-dependent compatibility arguments, clause elements, redirects, and attribution to an exact occurrence cwd. Unknown joins -clear those resolutions and retain the `` marker. Decoded child +clear those resolutions and retain the `` marker. Explicit +redirect targets also become Unknown unless provenance proves that the target +is cwd-independent; an unreachable body never borrows the parse-time cwd. +Decoded child hosts retain inherited invocation-cwd attribution on their compatibility -leaves while isolating child exit state. +leaves while isolating child exit state. A redirect authored outside the +decoded wrapper payload is evaluated in the invocation scope before child +launch, so its target may use a bounded parent-loop binding; redirects authored +inside the decoded payload continue to use child scope. When decoded child +hosts are nested, an outer redirect retains the outermost invocation scope +that authored it rather than binding to the nearest decoded child scope. Stable v0.3 continues to defer `while`, `if`, `elseif`, `else`, `do`, `switch`, functions, definitions, class/type bodies, and arbitrary execution-bearing @@ -727,11 +735,18 @@ Operators terminate the current token without surrounding whitespace — ### Redirect tokenization -Recognized redirect operators, longest-match first: `>`, `>>`, `<`; `N>` and +Recognized redirect operators, longest-match first: `>`, `>>`; `N>` and `N>>` for stream `N` in `{1,2,3,4,5,6}`; `*>` and `*>>` (all streams); and -the stream-merge form `N>&N` (e.g. `2>&1`). A redirect target of `$null` is -recognized as the discard sink. §8 covers how stream numbers map onto the -`RedirectDirection` enum. +the stream-merge forms `N>&1` for source stream `N` in `{2,3,4,5,6}` and +`*>&1`. PowerShell reserves `<` for future use, success stream `1` cannot be +a merge source, and merge targets other than success stream `1` are syntax +errors. A redirect target of `$null` or `${null}` is recognized as the discard +sink. §8 covers how stream numbers map onto the `RedirectDirection` enum. + +One command may redirect each source at most once. The unnumbered output +source and explicit stream `1` are the same source; a merge and a file redirect +also conflict when they consume the same numbered source. `*` remains its own +source and may coexist with a numbered redirect, matching native PowerShell. --- @@ -1098,8 +1113,9 @@ with the PowerShell-specific steps below. Resolution order: makes subsequent relative paths `DynamicSkip` (the working-directory- unknown mechanism, `SPEC.md` §9). -A redirect target of `$null` sets `Redirect.IsDynamicSkip = true` — it is -the discard sink, not a file; do not resolve it. The `LooksLikePath` +A redirect target of `$null` or `${null}` sets +`Redirect.IsDynamicSkip = true` — it is the discard sink, not a file; do not +resolve it. The `LooksLikePath` heuristic (`SPEC.md` §8) additionally recognizes a leading `FileSystem::` / `Microsoft.PowerShell.Core\FileSystem::` qualifier. @@ -1174,22 +1190,24 @@ about which stream produced it: | PowerShell redirect | `RedirectDirection` | |---|---| -| `<` | `In` | | `>`, `1>` | `Out` | | `>>`, `1>>` | `Append` | | `2>` | `ErrOut` | | `2>>` | `ErrAppend` | | `3>`–`6>`, `*>` | `Out` (lossy — warning/verbose/debug/information/all) | | `3>>`–`6>>`, `*>>` | `Append` (lossy) | -| stream merge `N>&M` (`2>&1`, `3>&1`, ...) | `ErrOut` when `N` is `2`, else `Out`; `Target` carries `&M` verbatim with `IsDynamicSkip=true` | +| stream merge `N>&1` for `N` in `2`–`6`, or `*>&1` | `ErrOut` when `N` is `2`, else `Out`; `Target` carries `&1` verbatim with `IsDynamicSkip=true` | The table above remains the v0.2 `Redirect` compatibility mapping. v0.3 also populates `RedirectAnalysis`: `RedirectSourceKind.PowerShellAllStreams` preserves `*`, `Descriptor` preserves numeric streams, and `Operation` -distinguishes file input/output/append from static descriptor duplication, -close, and move. Static descriptor operations are not path-relevant. A -variable, substitution, malformed suffix, or otherwise computed descriptor -target remains unknown or incomplete rather than becoming a static exemption. +distinguishes file output/append from static descriptor duplication. Static +descriptor operations are not path-relevant. PowerShell's grammar does not +admit descriptor close, move, computed merge targets, or file input +redirection; those spellings make the whole parse unparseable. `$null` and +`${null}` +remains an incomplete explicit redirect until the public operation vocabulary +has a discard-sink representation; consumers must continue to fail closed. --- diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index d583391..5f731da 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -408,6 +408,58 @@ PowerShell streams 3-6 and `*>` currently map lossily onto the shared redirect enum. The target remains available for path policy, but consumers must not use `RedirectDirection` to recover the exact original PowerShell stream. +In v0.3, authorize the parser-owned facts on every occurrence instead of +re-parsing `ClauseElement.Raw` or the compatibility target: + +```csharp +foreach (var redirect in occurrence.Redirects) +{ + if (!redirect.IsComplete) + { + return GateDecision.Prompt("redirect analysis is incomplete"); + } + + if (!redirect.IsPathRelevant) + { + EvaluateDescriptorOperation( + redirect.Source, + redirect.Operation, + redirect.TargetDescriptor); + continue; + } + + if (redirect.Target.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet)) + { + return GateDecision.Prompt("redirect path is unknown"); + } + + foreach (var path in redirect.Target.Values) + { + EvaluatePath(path); + } +} +``` + +Completeness and value precision are intentionally independent. For example, +`Get-Date > $name` has a complete file-output operation with an `Unknown` +target, so path policy still prompts. Under a caller-enforced isolated +PowerShell initial state, `foreach ($f in @('one.txt','two.txt')) { +Write-Output x > $f }` can instead expose a finite set of two absolute target +paths. The loop target is not added to `EffectiveArguments`, because a +redirect operand is not part of the command's argv. + +PowerShell stream facts retain numbered sources and the all-streams selector: +`3>&1` is a complete non-path descriptor duplication from stream `3` to stream +`1`, while `*>&1` retains `PowerShellAllStreams`. PowerShell itself rejects +`< input.txt`, `1>&1`, `2>&3`, and `2>&-`; ShellSyntaxTree therefore marks the +whole input unparseable rather than borrowing Bash descriptor rules. `$null` +and `${null}` remain incomplete in the v0.3 model, so consumers must prompt or +deny until a dedicated discard-sink operation is added. Native-invalid +duplicate sources such as `> a 1> b` and `2>&1 2> b` also make the whole parse +unparseable; consumers never need to reconcile competing facts for one +PowerShell source stream. + ## Compounds, pipelines, and wrapped commands `ParsedCommand.Clauses` is ordered. Each clause carries the operator that @@ -428,6 +480,11 @@ verb-based policy should authorize; the surfaced inner clauses are. 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`. +The outer redirect is evaluated by the invoking PowerShell scope before child +launch. It can therefore retain a finite parent-loop target domain even when +the decoded child occurrence remains incomplete for independent child-runspace +reasons. A redirect written inside the decoded `-Command` payload uses child +scope instead. Supported PowerShell `$()` subexpressions are structural rather than hidden opaque values. The containing `SimpleCommandSyntax.Substitutions` records each diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index f967c75..eb7c882 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -309,6 +309,10 @@ observations on Linux: | Each shell redirects to its escaped-dollar spelling of `$HOME".txt"` | Each creates exactly one file named `$HOME.txt` | Adjacent redirect fragments form one target | | Bash redirects unquoted and quoted `*.txt` in a directory with multiple `.txt` files | The unquoted form is an ambiguous redirect; the quoted form creates the literal file `*.txt` | `BashRedirect` requires one proved target and retains quote-sensitive glob eligibility | | PowerShell redirects quoted `~`, `*.txt`, `FileSystem::...`, and a FileSystem PSDrive path | Tilde, wildcard, provider, and drive semantics apply after quote removal | `PowerShellRedirect` is Path-like but remains separate from cmdlet and native argument contexts | +| PowerShell parses `2>&1`, `6>&1`, and `*>&1`, but rejects `1>&1`, `2>&3`, and `*>&2` | Only non-success numbered streams or all streams can merge into success stream `1` | Explicit facts preserve the source stream and descriptor target `1`; all other merge shapes fail closed as syntax errors | +| PowerShell parses `<` as a reserved-token syntax error | PowerShell 7.6 has no file-input redirection operator | `<` makes the whole parse unparseable rather than producing a compatibility `In` redirect | +| PowerShell rejects `> a > b`, `> a 1> b`, `2>&1 2> b`, and `*> a *> b`, but accepts `*> a 2> b` | Each exact source may be redirected once; default output and stream `1` are identical while `*` is independent from numbered sources | Duplicate-source validation precedes publication of compatibility or explicit facts | +| PowerShell treats `$null` and `${null}` as the same redirect sink | Bracing does not turn the automatic null variable into a filename | Both spellings remain explicit but incomplete until the public model has a discard-sink operation | Task 2.2 converts these probes into deterministic shell-oracle regressions on the implementation branch; the design corpus records their desired semantic @@ -726,10 +730,17 @@ the occurrence analyzer then supplies the authoritative exact-or-unknown cwd facts. Outcome projection rebases cwd-dependent compatibility arguments, elements, redirects, and attribution when that occurrence cwd is exact. An unknown occurrence cwd clears those resolutions and retains the dynamic-cwd -marker, so a parse location taken from the success partition cannot leak into -an exact failure continuation. Decoded child-host compatibility leaves carry +marker. Explicit redirect targets likewise become Unknown unless their +provenance resolves without a cwd, so a parse location taken from a success or +unreachable partition cannot leak into an exact failure continuation. Decoded +child-host compatibility leaves carry the inherited invocation-cwd attribution needed by that projection, while the -child's exit state remains isolated. General extraction of state primitives is +child's exit state remains isolated. Redirect provenance distinguishes an +outer wrapper redirect evaluated by the parent before child launch from an +inner decoded redirect evaluated in child scope; only the outer form may use a +bounded parent-loop binding. Nested decoded hosts do not change that owner: the +outer redirect keeps the outermost invocation context that authored it rather +than using the nearest child context. General extraction of state primitives is a post-v0.3 refactor and proceeds only after both language passes demonstrate identical behavior. diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md index 0faa7b2..bd4f058 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md @@ -485,6 +485,23 @@ partition merely to publish exact continuation facts. - **THEN** each reached body occurrence retains the exact incoming cwd - **THEN** a structurally present but unreachable body or continuation still receives conservative cwd facts +#### Scenario: Unreachable relative redirect has no parse-time cwd proof +- **WHEN** isolated-mode PowerShell parses `foreach ($x in @()) { Write-Output x > relative.txt }` +- **THEN** the body occurrence and its working directory remain incomplete or Unknown +- **THEN** the explicit redirect target is Unknown rather than the parse-time absolute path +- **THEN** an authored absolute redirect target may remain exact because it is cwd-independent + +#### Scenario: Outer child-host redirect uses parent binding +- **WHEN** isolated-mode PowerShell parses `foreach ($f in @('one.txt','two.txt')) { pwsh -Command 'Get-Date' > $f }` +- **THEN** the outer redirect target is the finite set of two parent-cwd paths +- **THEN** the decoded child command may remain independently incomplete because child runspace facts are not inferred +- **THEN** an inner redirect authored inside the decoded payload does not inherit the parent loop binding + +#### Scenario: Nested child hosts retain outer redirect ownership +- **WHEN** the preceding outer redirect wraps two or more static `pwsh -Command` or `-EncodedCommand` child hosts +- **THEN** the outer redirect target still uses the parent loop binding +- **THEN** it does not bind to the nearest decoded child invocation scope + #### Scenario: Duplicate iteration values retain order - **WHEN** isolated-mode Bash parses `for f in a b a; do :; done; printf '%s' "$f"` - **THEN** the internal iteration plan retains `a`, `b`, `a` in that order diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md index ee2f296..24e62fe 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/explicit-redirect-semantics/spec.md @@ -17,6 +17,26 @@ SHALL be incomplete. - **THEN** the source is explicitly PowerShell all streams - **THEN** the target is a path-relevant file output rather than a guessed numeric descriptor +#### Scenario: PowerShell static stream merge +- **WHEN** PowerShell parses `Get-ChildItem 3>&1` +- **THEN** the source is descriptor `3` and the operation duplicates to descriptor `1` +- **THEN** the target is not path-relevant + +#### Scenario: Unsupported PowerShell redirect grammar fails closed +- **WHEN** PowerShell receives `< input.txt`, `1>&1`, `2>&3`, or `2>&-` +- **THEN** the whole parse is unparseable in agreement with the native parser +- **THEN** no compatibility or explicit redirect fact is guessed from the malformed prefix + +#### Scenario: Duplicate PowerShell source redirect fails closed +- **WHEN** PowerShell receives `> a > b`, `> a 1> b`, `2>&1 2> b`, or `*> a *> b` +- **THEN** the whole parse is unparseable because one source is redirected twice +- **THEN** `*> a 2> b` remains valid because all streams and stream `2` are distinct authored sources + +#### Scenario: PowerShell null sink spellings agree +- **WHEN** PowerShell parses either `> $null` or `> ${null}` +- **THEN** neither spelling is published as a path-relevant file target +- **THEN** the explicit fact remains incomplete until a discard-sink operation is represented + #### Scenario: Static descriptor duplication - **WHEN** Bash parses `dotnet test 2>&1` - **THEN** the redirect operation is descriptor duplicate diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 966631c..6db4c43 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -68,8 +68,8 @@ - [x] 4.2 Classify Bash descriptor duplication, close, and move as static only for the complete literal descriptor grammar. - [x] 4.3 Keep variable-driven and otherwise computed Bash descriptor targets unknown or incomplete. - [x] 4.4 Lex and classify Bash `&>` and `&>>` independently from background-list operators. -- [ ] 4.5 Map existing PowerShell stream redirects into the shared explicit model without losing shell-specific stream identity. -- [ ] 4.6 Add paired direct tests and corpus cases for static, dynamic, malformed, multiple, combined, and file redirects. +- [x] 4.5 Map existing PowerShell stream redirects into the shared explicit model without losing shell-specific stream identity. +- [x] 4.6 Add paired direct tests and corpus cases for static, dynamic, malformed, multiple, combined, and file redirects. - [x] 4.6a Add Bash direct and executable-corpus cases for static duplicate, close, and move; computed descriptor targets; combined output overwrite and append; ordinary file redirects; arbitrary numeric source descriptors; and @@ -77,7 +77,7 @@ descriptor boundary and inside multi-digit sources follow Bash's pre-token removal semantics. Executable-corpus cases pin malformed atomic failure and independent occurrence facts for multiple redirects. - - [ ] 4.6b Add the paired PowerShell direct and executable-corpus cases when + - [x] 4.6b Add the paired PowerShell direct and executable-corpus cases when task 4.5 maps its stream model. - [ ] 4.7 Verify the explicit model removes the need for raw-prefix inference in a Netclaw integration test. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs index dc55d9b..5e26fdc 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Lexing/PwshLexer.cs @@ -255,9 +255,10 @@ private static bool TryReadOperator( /// /// SPEC.POWERSHELL.md §5: recognize redirect operators, longest-match - /// first — >, >>, <; N> / + /// first — >, >>; N> / /// N>> for stream N in {1..6}; *> / *>>; - /// and the stream-merge form N>&M. + /// and the stream-merge forms N>&1 for N in {2..6} and + /// *>&1. /// private static bool TryReadRedirect( ReadOnlySpan src, int i, out int length, out string? text) @@ -265,13 +266,13 @@ private static bool TryReadRedirect( length = 0; text = null; - var prefixed = false; + var prefix = '\0'; var p = i; if (src[i] == '*' || (src[i] >= '1' && src[i] <= '6')) { if (i + 1 < src.Length && src[i + 1] == '>') { - prefixed = true; + prefix = src[i]; p = i + 1; } else @@ -290,7 +291,7 @@ private static bool TryReadRedirect( if (op == '<') { // '<' takes no stream prefix. - if (prefixed) + if (prefix != '\0') { return false; } @@ -305,9 +306,11 @@ private static bool TryReadRedirect( return false; } - // Stream-merge: '>' '&' digit. + // PowerShell only permits non-success streams (or all streams) to + // merge into success stream 1. if (p + 2 < src.Length && src[p + 1] == '&' - && src[p + 2] >= '1' && src[p + 2] <= '6') + && src[p + 2] == '1' + && (prefix == '*' || prefix is >= '2' and <= '6')) { length = (p + 3) - i; text = src.Slice(i, length).ToString(); diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index 6043677..212509f 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -850,6 +850,17 @@ private static ArgResult ExtractArgsAndRedirects( pendingValueParam = null; pendingNativeFlag = null; pendingAmbiguousBinding = false; + if (IsRedirectSourceAlreadyUsed( + elements, + t.OperatorText ?? string.Empty)) + { + return new ArgResult( + args, + redirects, + elements, + "PowerShell command redirects the same source stream more than once"); + } + var consumed = BuildRedirect( body, i, @@ -1409,6 +1420,12 @@ private static int BuildRedirect( error = null; var operatorToken = body[opIndex]; var op = operatorToken.OperatorText ?? string.Empty; + if (op == "<") + { + error = "PowerShell input redirection '<' is reserved for future use"; + return 1; + } + var direction = MapRedirect(op, out var isMerge, out var mergeTarget); if (isMerge) @@ -2373,6 +2390,17 @@ private static bool TryBuildWrapperRedirects( return false; } + if (IsRedirectSourceAlreadyUsed( + elementList, + body[i].OperatorText ?? string.Empty)) + { + redirects = redirectList; + elements = elementList; + failure = + "PowerShell command redirects the same source stream more than once"; + return false; + } + var consumed = BuildRedirect( body, i, @@ -2404,6 +2432,46 @@ private static bool IsRedirectOperator(PwshToken token) => && token.OperatorText is not null && (token.OperatorText == "<" || token.OperatorText.IndexOf('>') >= 0); + private static bool IsRedirectSourceAlreadyUsed( + IReadOnlyList elements, + string redirectOperator) + { + if (redirectOperator == "<") + { + return false; + } + + var source = GetRedirectSourceKey(redirectOperator); + foreach (var element in elements) + { + if (element.Role == ClauseElementRole.Redirect && + GetRedirectSourceKey(element.Raw) == source) + { + return true; + } + } + + return false; + } + + private static int GetRedirectSourceKey(string redirectOperator) + { + if (redirectOperator.Length > 0 && redirectOperator[0] == '*') + { + return -1; + } + + if (redirectOperator.Length > 0 && + redirectOperator[0] is >= '1' and <= '6') + { + return redirectOperator[0] - '0'; + } + + // PowerShell's unnumbered output redirect and explicit stream 1 are + // the same source for duplicate-redirection validation. + return 1; + } + private static string? NextTokenValue(List body, int paramIndex) => paramIndex + 1 < body.Count ? body[paramIndex + 1].Value : null; diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs index 7093423..09631a9 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs @@ -1671,6 +1671,7 @@ internal sealed class PwshForEachValueAnalyzer new(ClauseReferenceComparer.Instance); private readonly Dictionary> _executionRegions = new(ClauseReferenceComparer.Instance); + private readonly List _invocationRedirectContexts = new(); private bool _isComplete = true; private int _remainingLoopAnalysisTransitions = MaxLoopAnalysisTransitions; private long _executionRegionEffectCount; @@ -1792,6 +1793,7 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext current, includeUnresolved: isForEachIncomplete || current.CommandResolutionInvalidated); + var redirects = AnalyzeRedirects(source, current); var mayPromote = current.CanPromote && source.HasCompleteValueProvenance && simple.Substitutions.Count == 0 && @@ -1802,6 +1804,7 @@ private PwshFlowResult AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext current, source, effective, + redirects, isForEachIncomplete, mayPromote); @@ -2662,6 +2665,7 @@ private void RecordFacts( AnalysisContext input, CommandOccurrenceFacts source, IReadOnlyList effective, + IReadOnlyList redirects, bool isForEachIncomplete, bool mayPromote) { @@ -2669,7 +2673,8 @@ private void RecordFacts( { EffectiveArguments = effective, WorkingDirectory = input.ToWorkingDirectoryDomain(), - Redirects = source.Redirects, + Redirects = redirects, + RedirectTargetProvenance = source.RedirectTargetProvenance, CwdPathDependencies = source.CwdPathDependencies, ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, @@ -2691,7 +2696,8 @@ private void RecordFacts( WorkingDirectory = JoinWorkingDirectories( prior.WorkingDirectory, current.WorkingDirectory), - Redirects = source.Redirects, + Redirects = JoinRedirects(prior.Redirects, current.Redirects), + RedirectTargetProvenance = source.RedirectTargetProvenance, CwdPathDependencies = source.CwdPathDependencies, ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, @@ -2699,6 +2705,136 @@ private void RecordFacts( }; } + private IReadOnlyList AnalyzeRedirects( + CommandOccurrenceFacts source, + AnalysisContext input) + { + if (source.Redirects.Count == 0 || + source.RedirectTargetProvenance.Count == 0) + { + return source.Redirects; + } + + var rewritten = new RedirectAnalysis[source.Redirects.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + rewritten[index] = source.Redirects[index]; + } + + foreach (var provenance in source.RedirectTargetProvenance) + { + var evaluationContext = provenance.UsesOutermostInvocationScope && + _invocationRedirectContexts.Count > 0 + ? _invocationRedirectContexts[0] + : input; + if (provenance.RedirectIndex < 0 || + provenance.RedirectIndex >= rewritten.Length || + !rewritten[provenance.RedirectIndex].IsPathRelevant || + !evaluationContext.TryEvaluateValue( + provenance.Value, + out var domain) || + domain.Kind is not ( + ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet)) + { + continue; + } + + var values = new List(); + var distinct = new HashSet(StringComparer.Ordinal); + var resolutionOptions = _options with + { + WorkingDirectory = evaluationContext.WorkingDirectory, + }; + var resolvedAll = true; + foreach (var candidate in domain.Values) + { + var resolved = PwshResolver.Resolve( + ShellValue.Literal(candidate), + treatAsPath: true, + resolutionOptions, + workingDirectoryUnknown: + evaluationContext.WorkingDirectory is null, + ShellResolutionConsumer.PowerShellRedirect); + if (resolved.Resolved is null || !resolved.IsPath) + { + resolvedAll = false; + break; + } + + if (distinct.Add(resolved.Resolved)) + { + values.Add(resolved.Resolved); + } + } + + rewritten[provenance.RedirectIndex] = + rewritten[provenance.RedirectIndex] with + { + Target = resolvedAll + ? CreateDomain(values) + : ShellValueDomain.Unknown, + }; + } + + return rewritten; + } + + private static IReadOnlyList JoinRedirects( + IReadOnlyList left, + IReadOnlyList right) + { + if (left.Count != right.Count) + { + return Array.Empty(); + } + + var joined = new RedirectAnalysis[left.Count]; + for (var index = 0; index < joined.Length; index++) + { + var leftFact = left[index]; + var rightFact = right[index]; + if (leftFact.RedirectIndex != rightFact.RedirectIndex || + leftFact.Source != rightFact.Source || + leftFact.Operation != rightFact.Operation || + leftFact.TargetDescriptor != rightFact.TargetDescriptor || + leftFact.IsPathRelevant != rightFact.IsPathRelevant || + leftFact.HereDocument != rightFact.HereDocument) + { + joined[index] = new RedirectAnalysis + { + RedirectIndex = leftFact.RedirectIndex, + }; + continue; + } + + joined[index] = leftFact with + { + Target = JoinDomains(leftFact.Target, rightFact.Target), + IsComplete = leftFact.IsComplete && rightFact.IsComplete, + }; + } + + return joined; + } + + private static ShellValueDomain CreateDomain(IReadOnlyList values) => + values.Count switch + { + 1 => new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { values[0] }, + }, + _ when values.Count > 1 && + values.Count <= ShellAnalysisLimits.MaxValueCandidates => + new ShellValueDomain + { + Kind = ShellValueDomainKind.FiniteSet, + Values = values, + }, + _ => ShellValueDomain.Unknown, + }; + private PwshFlowResult AnalyzeList(CommandListSyntax list, AnalysisContext input) { if (list.Items.Count == 0) @@ -2934,11 +3070,20 @@ private PwshFlowResult AnalyzeGroup(GroupSyntax group, AnalysisContext input) var childScopeEscapeRiskCount = _childScopeEscapeRiskCount; var childRunspaceProcessEscapeRiskCount = _childRunspaceProcessEscapeRiskCount; - AnalyzeBlock( - group.Body, - input.WithoutBindings().Invalidate( - unknownCwd: false, - invalidateCommandResolution: false)); + _invocationRedirectContexts.Add(input); + try + { + AnalyzeBlock( + group.Body, + input.WithoutBindings().Invalidate( + unknownCwd: false, + invalidateCommandResolution: false)); + } + finally + { + _invocationRedirectContexts.RemoveAt( + _invocationRedirectContexts.Count - 1); + } _executionRegionEffectCount = executionRegionEffectCount; _nonRegionStateMutationCount = nonRegionStateMutationCount; _locationStateMutationCount = locationStateMutationCount; @@ -3347,7 +3492,8 @@ private CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) { EffectiveArguments = source.EffectiveArguments, WorkingDirectory = ShellValueDomain.Unknown, - Redirects = source.Redirects, + Redirects = RewriteRedirectsForUnknownState(source), + RedirectTargetProvenance = source.RedirectTargetProvenance, CwdPathDependencies = source.CwdPathDependencies, ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, @@ -3450,11 +3596,16 @@ private SimpleCommandSyntax RewriteSimple( simple.Clause, source, out var hasUnresolvedCwdDynamicElement); + var redirects = RewriteRedirectFacts( + source.Redirects, + source.RedirectTargetProvenance, + clause); facts.Add(clause, new CommandOccurrenceFacts { EffectiveArguments = source.EffectiveArguments, WorkingDirectory = source.WorkingDirectory, - Redirects = source.Redirects, + Redirects = redirects, + RedirectTargetProvenance = source.RedirectTargetProvenance, CwdPathDependencies = source.CwdPathDependencies, ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, @@ -3468,6 +3619,61 @@ private SimpleCommandSyntax RewriteSimple( }; } + private static IReadOnlyList RewriteRedirectFacts( + IReadOnlyList source, + IReadOnlyList provenance, + Clause clause) + { + if (source.Count == 0) + { + return source; + } + + var rewritten = new RedirectAnalysis[source.Count]; + for (var index = 0; index < rewritten.Length; index++) + { + var fact = source[index]; + if (!fact.IsPathRelevant || + fact.RedirectIndex < 0 || + fact.RedirectIndex >= clause.Redirects.Count) + { + rewritten[index] = fact; + continue; + } + + var compatibility = clause.Redirects[fact.RedirectIndex]; + rewritten[index] = fact with + { + Target = compatibility.IsDynamicSkip + ? HasRedirectProvenance(provenance, fact.RedirectIndex) + ? fact.Target + : ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { compatibility.Target }, + }, + }; + } + + return rewritten; + } + + private static bool HasRedirectProvenance( + IReadOnlyList provenance, + int redirectIndex) + { + foreach (var item in provenance) + { + if (item.RedirectIndex == redirectIndex) + { + return true; + } + } + + return false; + } + private Clause RewriteCwdCompatibility( Clause clause, CommandOccurrenceFacts facts, @@ -4030,7 +4236,8 @@ private void RecordUnvisitedBindingArguments( { EffectiveArguments = effective.ToArray(), WorkingDirectory = ShellValueDomain.Unknown, - Redirects = source.Redirects, + Redirects = RewriteRedirectsForUnknownState(source), + RedirectTargetProvenance = source.RedirectTargetProvenance, CwdPathDependencies = source.CwdPathDependencies, ValueProvenance = source.ValueProvenance, HasCompleteValueProvenance = source.HasCompleteValueProvenance, @@ -4072,6 +4279,69 @@ private void RecordUnvisitedBindingArguments( } } + private IReadOnlyList RewriteRedirectsForUnknownState( + CommandOccurrenceFacts source) + { + if (source.Redirects.Count == 0) + { + return source.Redirects; + } + + var redirects = new RedirectAnalysis[source.Redirects.Count]; + for (var index = 0; index < redirects.Length; index++) + { + var fact = source.Redirects[index]; + if (!fact.IsPathRelevant || + !TryGetRedirectProvenance( + source.RedirectTargetProvenance, + fact.RedirectIndex, + out var value)) + { + redirects[index] = fact.IsPathRelevant + ? fact with { Target = ShellValueDomain.Unknown } + : fact; + continue; + } + + var resolved = PwshResolver.Resolve( + value, + treatAsPath: true, + _options with { WorkingDirectory = null }, + workingDirectoryUnknown: true, + ShellResolutionConsumer.PowerShellRedirect); + redirects[index] = fact with + { + Target = resolved.Resolved is not null && resolved.IsPath + ? new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { resolved.Resolved }, + } + : ShellValueDomain.Unknown, + }; + } + + return redirects; + } + + private static bool TryGetRedirectProvenance( + IReadOnlyList provenance, + int redirectIndex, + out ShellValue value) + { + foreach (var item in provenance) + { + if (item.RedirectIndex == redirectIndex) + { + value = item.Value; + return true; + } + } + + value = ShellValue.Literal(string.Empty); + return false; + } + private static bool ReferencesBinding(ShellValue value, string bindingName) { foreach (var fragment in value.Fragments) diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshRedirectAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshRedirectAnalysis.cs new file mode 100644 index 0000000..f0c05b1 --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshRedirectAnalysis.cs @@ -0,0 +1,170 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System; +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Pwsh.Parsing; + +/// +/// Builds occurrence-level PowerShell redirect facts without collapsing its +/// six numbered streams or all-streams selector into the v0.2 compatibility +/// direction. +/// +internal static class PwshRedirectAnalysis +{ + internal static IReadOnlyList Analyze(Clause clause) + { + if (clause.Redirects.Count == 0) + { + return Array.Empty(); + } + + var elements = new List(clause.Redirects.Count); + foreach (var element in clause.Elements) + { + if (element.Role == ClauseElementRole.Redirect) + { + elements.Add(element); + } + } + + var result = new RedirectAnalysis[clause.Redirects.Count]; + for (var index = 0; index < result.Length; index++) + { + result[index] = index < elements.Count + ? Analyze(index, clause.Redirects[index], elements[index]) + : Incomplete(index); + } + + return result; + } + + private static RedirectAnalysis Analyze( + int redirectIndex, + Redirect compatibility, + ClauseElement element) + { + if (!TryReadOperator( + element.Raw, + out var source, + out var operation, + out var operatorLength)) + { + return Incomplete(redirectIndex); + } + + if (operation == RedirectOperation.DescriptorDuplicate) + { + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = operation, + TargetDescriptor = 1, + IsComplete = true, + }; + } + + var authoredTarget = element.Raw.Substring(operatorLength).TrimStart(); + if (compatibility.IsDynamicSkip && + compatibility.Target.Equals("$null", StringComparison.OrdinalIgnoreCase)) + { + // The public v0.3 operation vocabulary has no discard-sink member. + // Preserve the source coordinate, but keep the fact incomplete. + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + }; + } + + return new RedirectAnalysis + { + RedirectIndex = redirectIndex, + Source = source, + Operation = operation, + Target = compatibility.IsDynamicSkip + ? ShellValueDomain.Unknown + : new ShellValueDomain + { + Kind = ShellValueDomainKind.Exact, + Values = new[] { compatibility.Target }, + }, + IsPathRelevant = true, + // Completeness describes the redirect grammar, independently from + // whether the target value can be narrowed beyond Unknown. + IsComplete = authoredTarget.Length > 0, + }; + } + + private static bool TryReadOperator( + string raw, + out RedirectSource source, + out RedirectOperation operation, + out int length) + { + source = new RedirectSource { Kind = RedirectSourceKind.Default }; + operation = RedirectOperation.Unknown; + length = 0; + if (string.IsNullOrEmpty(raw)) + { + return false; + } + + var operatorStart = 0; + if (raw[0] == '*') + { + source = new RedirectSource + { + Kind = RedirectSourceKind.PowerShellAllStreams, + }; + operatorStart = 1; + } + else if (raw[0] is >= '1' and <= '6') + { + source = new RedirectSource + { + Kind = RedirectSourceKind.Descriptor, + Descriptor = raw[0] - '0', + }; + operatorStart = 1; + } + + if (raw.AsSpan(operatorStart).StartsWith(">&1", StringComparison.Ordinal)) + { + if (source.Kind == RedirectSourceKind.Default || source.Descriptor == 1) + { + return false; + } + + operation = RedirectOperation.DescriptorDuplicate; + length = operatorStart + 3; + return true; + } + + if (raw.AsSpan(operatorStart).StartsWith(">>", StringComparison.Ordinal)) + { + operation = RedirectOperation.FileAppend; + length = operatorStart + 2; + return true; + } + + if (operatorStart < raw.Length && raw[operatorStart] == '>') + { + operation = RedirectOperation.FileOutput; + length = operatorStart + 1; + return true; + } + + source = new RedirectSource(); + return false; + } + + private static RedirectAnalysis Incomplete(int redirectIndex) => new() + { + RedirectIndex = redirectIndex, + }; +} diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index b7bd2ef..fb0fbd4 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -138,10 +138,45 @@ internal StructuralCoordinator( internal CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) => _facts.TryGetValue(simple.Clause, out var facts) ? facts - : new CommandOccurrenceFacts + : CreateDefaultFacts(simple); + + private CommandOccurrenceFacts CreateDefaultFacts( + SimpleCommandSyntax simple) + { + var redirects = PwshRedirectAnalysis.Analyze(simple.Clause); + var redirectProvenance = new List(); + var redirectIndex = 0; + for (var elementIndex = 0; + elementIndex < simple.Clause.Elements.Count; + elementIndex++) + { + var element = simple.Clause.Elements[elementIndex]; + if (element.Role != ClauseElementRole.Redirect) { - IsComplete = IsStructurallyComplete(simple), - }; + continue; + } + + if (TryGetRedirectTargetValue(element, _tokens, out var value) || + TryGetAuthoredRedirectTargetValue(element, out value)) + { + redirectProvenance.Add(new RedirectTargetProvenance( + redirectIndex, + elementIndex, + value, + UsesOutermostInvocationScope: true)); + } + + redirectIndex++; + } + + return new CommandOccurrenceFacts + { + Redirects = redirects, + RedirectTargetProvenance = redirectProvenance.ToArray(), + IsComplete = IsStructurallyComplete(simple) && + AreRedirectsComplete(redirects), + }; + } internal PwshForEachAnalysisPlan? GetForEachPlan(ForEachSyntax forEach) => _forEachPlans.TryGetValue(forEach, out var plan) ? plan : null; @@ -1709,12 +1744,32 @@ private void RegisterFacts( IReadOnlyList sourceTokens) { var provenance = new List(); + var redirectProvenance = new List(); var hasCompleteProvenance = true; + var redirectIndex = 0; for (var elementIndex = 0; elementIndex < simple.Clause.Elements.Count; elementIndex++) { var element = simple.Clause.Elements[elementIndex]; + if (element.Role == ClauseElementRole.Redirect) + { + if (TryGetRedirectTargetValue( + element, + sourceTokens, + out var redirectValue)) + { + redirectProvenance.Add(new RedirectTargetProvenance( + redirectIndex, + elementIndex, + redirectValue, + UsesOutermostInvocationScope: false)); + } + + redirectIndex++; + continue; + } + if (element.Role != ClauseElementRole.Argument) { continue; @@ -1729,14 +1784,92 @@ private void RegisterFacts( provenance.Add(new ShellValueElementProvenance(elementIndex, value)); } + var redirects = PwshRedirectAnalysis.Analyze(simple.Clause); _facts.Add(simple.Clause, new CommandOccurrenceFacts { + Redirects = redirects, + RedirectTargetProvenance = redirectProvenance.ToArray(), ValueProvenance = provenance.ToArray(), HasCompleteValueProvenance = hasCompleteProvenance, - IsComplete = IsStructurallyComplete(simple), + IsComplete = IsStructurallyComplete(simple) && + AreRedirectsComplete(redirects), }); } + private static bool TryGetRedirectTargetValue( + ClauseElement element, + IReadOnlyList sourceTokens, + out ShellValue value) + { + value = ShellValue.Literal(string.Empty); + if (element.SourceStart is null || element.SourceLength is null) + { + return false; + } + + var elementStart = element.SourceStart.Value; + var elementEnd = elementStart + element.SourceLength.Value; + var values = new List(); + var sawOperator = false; + var targetEnd = -1; + foreach (var token in sourceTokens) + { + var tokenEnd = token.SourceStart + token.SourceLength; + if (token.SourceStart < elementStart || tokenEnd > elementEnd) + { + continue; + } + + if (!sawOperator) + { + if (token.SourceStart != elementStart || + token.Kind != PwshTokenKind.Operator) + { + return false; + } + + sawOperator = true; + continue; + } + + targetEnd = tokenEnd; + values.Add(token.ResolverValue ?? + ShellValue.Literal(token.Value, token.SourceStart, token.SourceLength)); + } + + if (!sawOperator || values.Count == 0 || targetEnd != elementEnd) + { + return false; + } + + value = values.Count == 1 ? values[0] : ShellValue.Concat(values); + return true; + } + + private static bool TryGetAuthoredRedirectTargetValue( + ClauseElement element, + out ShellValue value) + { + value = ShellValue.Literal(string.Empty); + if (element.SourceStart is null || element.SourceLength is null) + { + return false; + } + + var localTokens = PwshLexer.Tokenize(element.Raw); + foreach (var token in localTokens) + { + if (token.Kind == PwshTokenKind.UnparseableSentinel) + { + return false; + } + } + + var significant = FilterSignificant(localTokens); + var shifted = ShiftTokens(significant, element.SourceStart.Value); + return TryGetRedirectTargetValue(element, shifted, out value); + } + private static bool TryGetElementValue( ClauseElement element, IReadOnlyList sourceTokens, @@ -2214,8 +2347,7 @@ private static bool TryCloneDecodedCollection( private static bool IsStructurallyComplete(SimpleCommandSyntax simple) { var clause = simple.Clause; - if (clause.Redirects.Count > 0 || - clause.Verb.IsDynamic || + if (clause.Verb.IsDynamic || clause.Verb.Tokens.Count == 0) { return false; @@ -2239,6 +2371,20 @@ private static bool IsStructurallyComplete(SimpleCommandSyntax simple) return true; } + private static bool AreRedirectsComplete( + IReadOnlyList redirects) + { + foreach (var redirect in redirects) + { + if (!redirect.IsComplete) + { + return false; + } + } + + return true; + } + private static bool HasUnexpandedPowerShellCommandString(Clause clause) { var verb = clause.Verb.CanonicalVerb ?? diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs index 2e0c4d7..6bf8941 100644 --- a/src/ShellSyntaxTree/ShellSyntaxProjection.cs +++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs @@ -24,6 +24,9 @@ internal sealed class CommandOccurrenceFacts internal IReadOnlyList Redirects { get; init; } = Array.Empty(); + internal IReadOnlyList RedirectTargetProvenance { get; init; } = + Array.Empty(); + internal IReadOnlyList CwdPathDependencies { get; init; } = Array.Empty(); @@ -43,6 +46,17 @@ internal readonly record struct ShellValueElementProvenance( int ClauseElementIndex, ShellValue Value); +/// +/// Retains the shell-owned value fragments for one file-redirect target so +/// bounded state analysis can re-evaluate it without publishing it as an +/// effective command argument. +/// +internal readonly record struct RedirectTargetProvenance( + int RedirectIndex, + int ClauseElementIndex, + ShellValue Value, + bool UsesOutermostInvocationScope); + /// /// Retains resolver-owned path provenance and its exact compatibility /// coordinates for outcome-sensitive rebasing. Public compatibility DTOs do diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json index 281cf7c..3abcfae 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/128_redirect_out.json @@ -46,7 +46,7 @@ { "clauseIndex": 0, "immediateRole": "Ordinary", - "isComplete": false, + "isComplete": true, "ancestry": [ { "ancestorKind": "Block", @@ -55,6 +55,34 @@ "sourceStart": 0, "sourceLength": 18 } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/out.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } ] } ] diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/129_redirect_append.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/129_redirect_append.json index 2b4b8ce..3b25845 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/129_redirect_append.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/129_redirect_append.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 19, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 19, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 19 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileAppend", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/log.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "\u003E\u003E stdout append." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/130_redirect_err.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/130_redirect_err.json index 319ee06..a834713 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/130_redirect_err.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/130_redirect_err.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 22, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 22 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/err.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "2\u003E stderr redirect." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/131_redirect_err_append.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/131_redirect_err_append.json index 35a0595..d33e97f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/131_redirect_err_append.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/131_redirect_err_append.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "FileAppend", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/err.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "2\u003E\u003E stderr append." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/132_redirect_verbose.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/132_redirect_verbose.json index 4e18b46..2c5b695 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/132_redirect_verbose.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/132_redirect_verbose.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 23 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/verbose.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "3\u003E verbose stream \u2014 maps lossily to Out." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/133_redirect_warning.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/133_redirect_warning.json index d431f7e..fed069e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/133_redirect_warning.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/133_redirect_warning.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 4, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/warn.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "4\u003E warning stream \u2014 maps lossily to Out." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/134_redirect_debug.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/134_redirect_debug.json index dd86690..1e85faa 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/134_redirect_debug.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/134_redirect_debug.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 21, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 21, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 21 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 5, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/debug.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "5\u003E debug stream \u2014 maps lossily to Out." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/135_redirect_information.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/135_redirect_information.json index 934de31..fdb1c93 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/135_redirect_information.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/135_redirect_information.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 6, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/info.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "6\u003E information stream \u2014 maps lossily to Out." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/136_redirect_all.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/136_redirect_all.json index d3dabce..4b0a8c5 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/136_redirect_all.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/136_redirect_all.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 19, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 19, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 19 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "PowerShellAllStreams", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/all.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "*\u003E all-streams redirect \u2014 maps lossily to Out." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/137_redirect_all_append.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/137_redirect_all_append.json index e8c21fd..9583d47 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/137_redirect_all_append.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/137_redirect_all_append.json @@ -17,6 +17,74 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 20 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "PowerShellAllStreams", + "sourceDescriptor": null, + "operation": "FileAppend", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/all.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } ] }, "notes": "*\u003E\u003E all-streams append." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/138_redirect_merge_2to1.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/138_redirect_merge_2to1.json index 7af14ff..9d385c4 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/138_redirect_merge_2to1.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/138_redirect_merge_2to1.json @@ -18,6 +18,72 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": true + } + ] + } ] }, "notes": "2\u003E\u00261 stream merge \u2014 target carries \u00261." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/139_redirect_merge_3to1.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/139_redirect_merge_3to1.json index e60065d..c8110a8 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/139_redirect_merge_3to1.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/139_redirect_merge_3to1.json @@ -18,6 +18,72 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 3, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": true + } + ] + } ] }, "notes": "3\u003E\u00261 stream merge." diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/140_redirect_null.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/140_redirect_null.json index d06c7e8..c51641a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/140_redirect_null.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/140_redirect_null.json @@ -18,7 +18,73 @@ } ] } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "Unknown", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": false + } + ] + } ] }, - "notes": "$null redirect target is the discard sink." + "notes": "$null remains explicit but incomplete until the public model represents the discard sink." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json index 7d27911..9411f3a 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/316_v03_substitution_redirect.json @@ -127,7 +127,7 @@ { "clauseIndex": 1, "immediateRole": "Ordinary", - "isComplete": false, + "isComplete": true, "ancestry": [ { "ancestorKind": "Block", diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/394_v03_redirect_multiple_explicit.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/394_v03_redirect_multiple_explicit.json new file mode 100644 index 0000000..94b0c73 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/394_v03_redirect_multiple_explicit.json @@ -0,0 +1,112 @@ +{ + "name": "V03 redirect multiple explicit", + "input": "Get-Date \u003E out.txt 2\u003E err.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "C:/work/out.txt" + }, + { + "direction": "ErrOut", + "target": "C:/work/err.txt" + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 29 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/out.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + }, + { + "redirectIndex": 1, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/err.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Every file redirect retains authored order, source stream, operation, and exact target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/395_v03_redirect_dynamic_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/395_v03_redirect_dynamic_target.json new file mode 100644 index 0000000..96e8699 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/395_v03_redirect_dynamic_target.json @@ -0,0 +1,90 @@ +{ + "name": "V03 redirect dynamic target", + "input": "Get-Date \u003E $name", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$name", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 16 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "The redirect grammar is complete while its runtime target value remains Unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/396_v03_redirect_merge_all_streams.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/396_v03_redirect_merge_all_streams.json new file mode 100644 index 0000000..0781825 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/396_v03_redirect_merge_all_streams.json @@ -0,0 +1,90 @@ +{ + "name": "V03 redirect merge all streams", + "input": "Get-Date *\u003E\u00261", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "\u00261", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 13 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "PowerShellAllStreams", + "sourceDescriptor": null, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "The all-streams selector remains distinct when merged into success stream 1." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/397_v03_redirect_merge_trailing_argument.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/397_v03_redirect_merge_trailing_argument.json new file mode 100644 index 0000000..b0c24e3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/397_v03_redirect_merge_trailing_argument.json @@ -0,0 +1,96 @@ +{ + "name": "V03 redirect merge trailing argument", + "input": "Get-Date 2\u003E\u00261-", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [ + { + "raw": "-", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [ + { + "direction": "ErrOut", + "target": "\u00261", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 14 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "DescriptorDuplicate", + "targetDescriptor": 1, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": true + } + ] + } + ] + }, + "notes": "PowerShell parses the merge as 2\u003E\u00261 and preserves the trailing hyphen as an argument." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/398_v03_redirect_foreach_finite_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/398_v03_redirect_foreach_finite_target.json new file mode 100644 index 0000000..a5e7831 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/398_v03_redirect_foreach_finite_target.json @@ -0,0 +1,154 @@ +{ + "name": "V03 redirect foreach finite target", + "input": "foreach ($f in @(\u0027one.txt\u0027, \u0027two.txt\u0027)) { Write-Output x \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "x", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 63, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027one.txt\u0027, \u0027two.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 23 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 41, + "sourceLength": 21, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 42, + "sourceLength": 19, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 63 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 63 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 21 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "FiniteSet", + "values": [ + "C:/work/one.txt", + "C:/work/two.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "An isolated bounded loop resolves the redirect target to a finite absolute path domain." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/399_v03_redirect_input_syntax_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/399_v03_redirect_input_syntax_error.json new file mode 100644 index 0000000..e2682ad --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/399_v03_redirect_input_syntax_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect input syntax error", + "input": "Get-Date \u003C input.txt", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell input redirection \u0027\u003C\u0027 is reserved for future use" + }, + "notes": "PowerShell reserves input redirection for future use." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/400_v03_redirect_success_merge_source_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/400_v03_redirect_success_merge_source_error.json new file mode 100644 index 0000000..f15cbff --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/400_v03_redirect_success_merge_source_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect success merge source error", + "input": "Get-Date 1\u003E\u00261", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "trailing \u0027\u0026\u0027 background-job operator is not supported in v0.2" + }, + "notes": "Success stream 1 cannot be a PowerShell merge source." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/401_v03_redirect_non_success_merge_target_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/401_v03_redirect_non_success_merge_target_error.json new file mode 100644 index 0000000..6100ef3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/401_v03_redirect_non_success_merge_target_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect non success merge target error", + "input": "Get-Date 2\u003E\u00263", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "trailing \u0027\u0026\u0027 background-job operator is not supported in v0.2" + }, + "notes": "PowerShell stream merges may target only success stream 1." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/402_v03_redirect_descriptor_close_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/402_v03_redirect_descriptor_close_error.json new file mode 100644 index 0000000..12b8e0e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/402_v03_redirect_descriptor_close_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect descriptor close error", + "input": "Get-Date 2\u003E\u0026-", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "trailing \u0027\u0026\u0027 background-job operator is not supported in v0.2" + }, + "notes": "PowerShell does not support Bash descriptor-close syntax." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/403_v03_redirect_braced_null_sink.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/403_v03_redirect_braced_null_sink.json new file mode 100644 index 0000000..b2239b9 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/403_v03_redirect_braced_null_sink.json @@ -0,0 +1,90 @@ +{ + "name": "V03 redirect braced null sink", + "input": "Get-Date \u003E ${null}", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$null", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 18 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "Unknown", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": false, + "isComplete": false + } + ] + } + ] + }, + "notes": "The braced null variable is the same incomplete discard sink as $null." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/404_v03_redirect_all_and_error_independent.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/404_v03_redirect_all_and_error_independent.json new file mode 100644 index 0000000..2690153 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/404_v03_redirect_all_and_error_independent.json @@ -0,0 +1,112 @@ +{ + "name": "V03 redirect all and error independent", + "input": "Get-Date *\u003E all.txt 2\u003E err.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "C:/work/all.txt" + }, + { + "direction": "ErrOut", + "target": "C:/work/err.txt" + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 30, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 30, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 30 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "PowerShellAllStreams", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/all.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + }, + { + "redirectIndex": 1, + "sourceKind": "Descriptor", + "sourceDescriptor": 2, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/work/err.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "PowerShell permits all-streams and a numbered source to be redirected independently." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/405_v03_redirect_duplicate_default_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/405_v03_redirect_duplicate_default_error.json new file mode 100644 index 0000000..980eae6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/405_v03_redirect_duplicate_default_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect duplicate default error", + "input": "Get-Date \u003E a \u003E b", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell command redirects the same source stream more than once" + }, + "notes": "PowerShell rejects a second redirect of the default output stream." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/406_v03_redirect_duplicate_default_explicit_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/406_v03_redirect_duplicate_default_explicit_error.json new file mode 100644 index 0000000..d159adf --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/406_v03_redirect_duplicate_default_explicit_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect duplicate default explicit error", + "input": "Get-Date \u003E a 1\u003E b", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell command redirects the same source stream more than once" + }, + "notes": "Default output and explicit stream 1 are the same redirect source." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/407_v03_redirect_duplicate_merge_source_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/407_v03_redirect_duplicate_merge_source_error.json new file mode 100644 index 0000000..ef9d17e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/407_v03_redirect_duplicate_merge_source_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect duplicate merge source error", + "input": "Get-Date 2\u003E\u00261 2\u003E b", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell command redirects the same source stream more than once" + }, + "notes": "A merge and file redirect cannot both consume error stream 2." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/408_v03_redirect_duplicate_all_streams_error.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/408_v03_redirect_duplicate_all_streams_error.json new file mode 100644 index 0000000..5b49cb7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/408_v03_redirect_duplicate_all_streams_error.json @@ -0,0 +1,9 @@ +{ + "name": "V03 redirect duplicate all streams error", + "input": "Get-Date *\u003E a *\u003E b", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "PowerShell command redirects the same source stream more than once" + }, + "notes": "PowerShell rejects a second all-streams redirect." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/409_v03_redirect_wrapper_joined_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/409_v03_redirect_wrapper_joined_cwd.json new file mode 100644 index 0000000..f94f6df --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/409_v03_redirect_wrapper_joined_cwd.json @@ -0,0 +1,206 @@ +{ + "name": "V03 redirect wrapper joined cwd", + "input": "Set-Location C:\\maybe; pwsh -Command \u0027Get-Date\u0027 \u003E relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Location" + ], + "args": [ + { + "raw": "C:\\maybe", + "kind": "Literal", + "isPath": true, + "resolved": "C:/maybe" + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Date" + ], + "args": [ + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [ + { + "direction": "Out", + "target": "relative.txt", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 62, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 21, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "Group", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 23, + "sourceLength": 39, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": "Sequence" + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 62 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 23, + "sourceLength": 39 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "A fallible cwd transition keeps the outer wrapper redirect target Unknown." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/410_v03_redirect_encoded_wrapper_joined_cwd.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/410_v03_redirect_encoded_wrapper_joined_cwd.json new file mode 100644 index 0000000..d2c280c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/410_v03_redirect_encoded_wrapper_joined_cwd.json @@ -0,0 +1,206 @@ +{ + "name": "V03 redirect encoded wrapper joined cwd", + "input": "Set-Location C:\\maybe; pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA== \u003E relative.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Set-Location" + ], + "args": [ + { + "raw": "C:\\maybe", + "kind": "Literal", + "isPath": true, + "resolved": "C:/maybe" + } + ], + "redirects": [] + }, + { + "operator": "Sequence", + "verb": [ + "Get-Date" + ], + "args": [ + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [ + { + "direction": "Out", + "target": "relative.txt", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 83, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "CommandList", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 83, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 1, + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 21, + "clauseIndex": 0, + "groupKind": null, + "listOperator": "None" + }, + { + "kind": "Group", + "parentIndex": 1, + "region": "Statement", + "childIndex": 1, + "sourceStart": 23, + "sourceLength": 60, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": "Sequence" + }, + { + "kind": "Block", + "parentIndex": 3, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 4, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 1, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 83 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 83 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + } + }, + { + "clauseIndex": 1, + "immediateRole": "Ordinary", + "isComplete": true, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 83 + }, + { + "ancestorKind": "CommandList", + "region": "Statement", + "childIndex": 1, + "sourceStart": 0, + "sourceLength": 83 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 23, + "sourceLength": 60 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Encoded child syntax retains outer redirect provenance across a joined cwd." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/411_v03_redirect_unreachable_wrapper_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/411_v03_redirect_unreachable_wrapper_binding.json new file mode 100644 index 0000000..c20dd97 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/411_v03_redirect_unreachable_wrapper_binding.json @@ -0,0 +1,180 @@ +{ + "name": "V03 redirect unreachable wrapper binding", + "input": "foreach ($f in @()) { pwsh -Command \u0027Get-Date\u0027 \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@()", + "iterableSourceStart": 15, + "iterableSourceLength": 3 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 3, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 21, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 22, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 21, + "sourceLength": 31 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 22, + "sourceLength": 29 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "An unreachable wrapper redirect stays visible, Unknown, and incomplete rather than borrowing a loop value." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/412_v03_redirect_unreachable_relative_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/412_v03_redirect_unreachable_relative_target.json new file mode 100644 index 0000000..e861c18 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/412_v03_redirect_unreachable_relative_target.json @@ -0,0 +1,155 @@ +{ + "name": "V03 redirect unreachable relative target", + "input": "foreach ($x in @()) { Write-Output x \u003E relative.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "x", + "kind": "Literal", + "isPath": false + }, + { + "raw": "\u003Cdynamic-cwd\u003E", + "kind": "DynamicSkip", + "isPath": false, + "isCwdAttribution": true + } + ], + "redirects": [ + { + "direction": "Out", + "target": "relative.txt", + "isDynamicSkip": true + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "x", + "bindingRaw": "$x", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@()", + "iterableSourceStart": 15, + "iterableSourceLength": 3 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 3, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 21, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 22, + "sourceLength": 29, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 21, + "sourceLength": 31 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "An unreachable relative redirect cannot retain the parser working directory as an exact target." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/413_v03_redirect_unreachable_absolute_target.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/413_v03_redirect_unreachable_absolute_target.json new file mode 100644 index 0000000..b34aeac --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/413_v03_redirect_unreachable_absolute_target.json @@ -0,0 +1,150 @@ +{ + "name": "V03 redirect unreachable absolute target", + "input": "foreach ($x in @()) { Write-Output x \u003E C:\\fixed.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Write-Output" + ], + "args": [ + { + "raw": "x", + "kind": "Literal", + "isPath": false + } + ], + "redirects": [ + { + "direction": "Out", + "target": "C:/fixed.txt" + } + ] + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "x", + "bindingRaw": "$x", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@()", + "iterableSourceStart": 15, + "iterableSourceLength": 3 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 3, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 21, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 22, + "sourceLength": 29, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 53 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 21, + "sourceLength": 31 + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "Exact", + "values": [ + "C:/fixed.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "A cwd-independent absolute redirect target remains exact even on an unreachable incomplete occurrence." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/414_v03_redirect_wrapper_parent_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/414_v03_redirect_wrapper_parent_binding.json new file mode 100644 index 0000000..fe2c549 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/414_v03_redirect_wrapper_parent_binding.json @@ -0,0 +1,185 @@ +{ + "name": "V03 redirect wrapper parent binding", + "input": "foreach ($f in @(\u0027one.txt\u0027, \u0027two.txt\u0027)) { pwsh -Command \u0027Get-Date\u0027 \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 73, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 73, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027one.txt\u0027, \u0027two.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 23 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 41, + "sourceLength": 31, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 42, + "sourceLength": 29, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 73 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 73 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 31 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 42, + "sourceLength": 29 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "FiniteSet", + "values": [ + "C:/work/one.txt", + "C:/work/two.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "The parent shell resolves an outer wrapper redirect from its bounded loop binding before child launch." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/415_v03_redirect_encoded_wrapper_parent_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/415_v03_redirect_encoded_wrapper_parent_binding.json new file mode 100644 index 0000000..39ca43b --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/415_v03_redirect_encoded_wrapper_parent_binding.json @@ -0,0 +1,185 @@ +{ + "name": "V03 redirect encoded wrapper parent binding", + "input": "foreach ($f in @(\u0027one.txt\u0027, \u0027two.txt\u0027)) { pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA== \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 94, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 94, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027one.txt\u0027, \u0027two.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 23 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 41, + "sourceLength": 52, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 42, + "sourceLength": 50, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 94 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 94 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 52 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 42, + "sourceLength": 50 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "FiniteSet", + "values": [ + "C:/work/one.txt", + "C:/work/two.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Encoded child syntax does not erase the parent-owned finite redirect target domain." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/416_v03_redirect_nested_wrapper_parent_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/416_v03_redirect_nested_wrapper_parent_binding.json new file mode 100644 index 0000000..8aadfab --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/416_v03_redirect_nested_wrapper_parent_binding.json @@ -0,0 +1,221 @@ +{ + "name": "V03 redirect nested wrapper parent binding", + "input": "foreach ($f in @(\u0027one.txt\u0027, \u0027two.txt\u0027)) { pwsh -Command \u0022pwsh -Command \u0027Get-Date\u0027\u0022 \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 89, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 89, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027one.txt\u0027, \u0027two.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 23 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 41, + "sourceLength": 47, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 42, + "sourceLength": 45, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 6, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 7, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 89 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 89 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 47 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 42, + "sourceLength": 45 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "FiniteSet", + "values": [ + "C:/work/one.txt", + "C:/work/two.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "A redirect outside nested command-string wrappers retains its outermost parent binding." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/417_v03_redirect_nested_encoded_wrapper_parent_binding.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/417_v03_redirect_nested_encoded_wrapper_parent_binding.json new file mode 100644 index 0000000..3a04cd2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/417_v03_redirect_nested_encoded_wrapper_parent_binding.json @@ -0,0 +1,221 @@ +{ + "name": "V03 redirect nested encoded wrapper parent binding", + "input": "foreach ($f in @(\u0027one.txt\u0027, \u0027two.txt\u0027)) { pwsh -EncodedCommand cAB3AHMAaAAgAC0ARQBuAGMAbwBkAGUAZABDAG8AbQBtAGEAbgBkACAAUgB3AEIAbABBAEgAUQBBAEwAUQBCAEUAQQBHAEUAQQBkAEEAQgBsAEEAQQA9AD0A \u003E $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "Get-Date" + ], + "args": [], + "redirects": [ + { + "direction": "Out", + "target": "$f", + "isDynamicSkip": true + } + ], + "isCommandStringWrapped": true + } + ], + "syntax": [ + { + "kind": "Block", + "parentIndex": null, + "region": "Unknown", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 190, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "ForEach", + "parentIndex": 0, + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 190, + "clauseIndex": null, + "groupKind": null, + "listOperator": null, + "bindingName": "f", + "bindingRaw": "$f", + "bindingSourceStart": 9, + "bindingSourceLength": 2, + "iterableRaw": "@(\u0027one.txt\u0027, \u0027two.txt\u0027)", + "iterableSourceStart": 15, + "iterableSourceLength": 23 + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "Iterator", + "childIndex": null, + "sourceStart": 15, + "sourceLength": 23, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 1, + "region": "LoopBody", + "childIndex": null, + "sourceStart": 41, + "sourceLength": 148, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 3, + "region": "Statement", + "childIndex": 0, + "sourceStart": 42, + "sourceLength": 146, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 4, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "Group", + "parentIndex": 5, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": "IsolatedScope", + "listOperator": null + }, + { + "kind": "Block", + "parentIndex": 6, + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": null, + "groupKind": null, + "listOperator": null + }, + { + "kind": "SimpleCommand", + "parentIndex": 7, + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null, + "clauseIndex": 0, + "groupKind": null, + "listOperator": null + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": false, + "ancestry": [ + { + "ancestorKind": "Block", + "region": "Root", + "childIndex": 0, + "sourceStart": 0, + "sourceLength": 190 + }, + { + "ancestorKind": "ForEach", + "region": "LoopBody", + "childIndex": null, + "sourceStart": 0, + "sourceLength": 190 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": 41, + "sourceLength": 148 + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": 42, + "sourceLength": 146 + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Group", + "region": "GroupBody", + "childIndex": null, + "sourceStart": null, + "sourceLength": null + }, + { + "ancestorKind": "Block", + "region": "Statement", + "childIndex": 0, + "sourceStart": null, + "sourceLength": null + } + ], + "effectiveArguments": [], + "workingDirectory": { + "kind": "Exact", + "values": [ + "C:/work" + ], + "pattern": null, + "coveringDirectory": null + }, + "redirects": [ + { + "redirectIndex": 0, + "sourceKind": "Default", + "sourceDescriptor": null, + "operation": "FileOutput", + "targetDescriptor": null, + "target": { + "kind": "FiniteSet", + "values": [ + "C:/work/one.txt", + "C:/work/two.txt" + ], + "pattern": null, + "coveringDirectory": null + }, + "isPathRelevant": true, + "isComplete": true + } + ] + } + ] + }, + "notes": "Nested encoded wrappers retain the invocation scope that owns the outer redirect." +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index 617a553..0766928 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -1080,12 +1080,89 @@ public void Literal_variable_spelling_does_not_receive_effective_binding_value() } [Fact] - public void Redirect_binding_stays_incomplete_until_redirect_analysis_lands() + public void Redirect_binding_resolves_to_a_bounded_path_domain() { - var result = ParseIsolated("foreach ($f in 'out.txt') { Write-Output x > $f }"); + var result = ParseIsolated( + "foreach ($f in @('one.txt', 'two.txt')) { Write-Output x > $f }"); Assert.False(result.IsUnparseable, result.UnparseableReason); - Assert.False(Assert.Single(result.Commands).IsComplete); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.FiniteSet, redirect.Target.Kind); + Assert.Equal( + new[] { "C:/work/one.txt", "C:/work/two.txt" }, + redirect.Target.Values); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Outer_wrapper_redirect_uses_parent_loop_binding_domain() + { + var result = ParseIsolated( + "foreach ($f in @('one.txt', 'two.txt')) { " + + "pwsh -Command 'Get-Date' > $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.FiniteSet, redirect.Target.Kind); + Assert.Equal( + new[] { "C:/work/one.txt", "C:/work/two.txt" }, + redirect.Target.Values); + Assert.True(redirect.IsComplete); + } + + [Theory] + [InlineData("pwsh -Command \"pwsh -Command 'Get-Date'\"")] + [InlineData("pwsh -EncodedCommand cAB3AHMAaAAgAC0ARQBuAGMAbwBkAGUAZABDAG8AbQBtAGEAbgBkACAAUgB3AEIAbABBAEgAUQBBAEwAUQBCAEUAQQBHAEUAQQBkAEEAQgBsAEEAQQA9AD0A")] + public void Nested_wrapper_redirect_uses_outermost_parent_loop_binding_domain( + string wrapper) + { + var result = ParseIsolated( + "foreach ($f in @('one.txt', 'two.txt')) { " + + $"{wrapper} > $f }}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.FiniteSet, redirect.Target.Kind); + Assert.Equal( + new[] { "C:/work/one.txt", "C:/work/two.txt" }, + redirect.Target.Values); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Unreachable_relative_redirect_does_not_retain_parse_time_cwd() + { + var result = ParseIsolated( + "foreach ($x in @()) { Write-Output x > relative.txt }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + Assert.Equal(ShellValueDomainKind.Unknown, command.WorkingDirectory.Kind); + Assert.True(Assert.Single(command.Clause.Redirects).IsDynamicSkip); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Unreachable_absolute_redirect_remains_cwd_independent() + { + var result = ParseIsolated( + "foreach ($x in @()) { Write-Output x > C:\\fixed.txt }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.Exact, redirect.Target.Kind); + Assert.Equal("C:/fixed.txt", Assert.Single(redirect.Target.Values)); } private static ParsedCommand Parse(string source) => new PwshParser( diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshRedirectAnalysisTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshRedirectAnalysisTests.cs new file mode 100644 index 0000000..5043302 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshRedirectAnalysisTests.cs @@ -0,0 +1,162 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Linq; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +/// Pins the lossless v0.3 PowerShell redirect projection. +public class PwshRedirectAnalysisTests +{ + [Theory] + [InlineData(">", RedirectSourceKind.Default, null, RedirectOperation.FileOutput)] + [InlineData(">>", RedirectSourceKind.Default, null, RedirectOperation.FileAppend)] + [InlineData("1>", RedirectSourceKind.Descriptor, 1, RedirectOperation.FileOutput)] + [InlineData("2>>", RedirectSourceKind.Descriptor, 2, RedirectOperation.FileAppend)] + [InlineData("6>", RedirectSourceKind.Descriptor, 6, RedirectOperation.FileOutput)] + [InlineData("*>", RedirectSourceKind.PowerShellAllStreams, null, RedirectOperation.FileOutput)] + [InlineData("*>>", RedirectSourceKind.PowerShellAllStreams, null, RedirectOperation.FileAppend)] + public void File_redirect_preserves_source_operation_and_path( + string op, + RedirectSourceKind sourceKind, + int? descriptor, + RedirectOperation operation) + { + var result = Parse($"Get-Date {op} out.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var occurrence = Assert.Single(result.Commands); + Assert.True(occurrence.IsComplete); + var redirect = Assert.Single(occurrence.Redirects); + Assert.Equal(0, redirect.RedirectIndex); + Assert.Equal(sourceKind, redirect.Source.Kind); + Assert.Equal(descriptor, redirect.Source.Descriptor); + Assert.Equal(operation, redirect.Operation); + Assert.Equal(ShellValueDomainKind.Exact, redirect.Target.Kind); + Assert.Equal("C:/work/out.txt", Assert.Single(redirect.Target.Values)); + Assert.True(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + } + + [Theory] + [InlineData("2>&1", RedirectSourceKind.Descriptor, 2)] + [InlineData("6>&1", RedirectSourceKind.Descriptor, 6)] + [InlineData("*>&1", RedirectSourceKind.PowerShellAllStreams, null)] + public void Stream_merge_preserves_source_and_success_stream_target( + string op, + RedirectSourceKind sourceKind, + int? sourceDescriptor) + { + var result = Parse($"Get-Date {op}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var occurrence = Assert.Single(result.Commands); + Assert.True(occurrence.IsComplete); + var redirect = Assert.Single(occurrence.Redirects); + Assert.Equal(sourceKind, redirect.Source.Kind); + Assert.Equal(sourceDescriptor, redirect.Source.Descriptor); + Assert.Equal(RedirectOperation.DescriptorDuplicate, redirect.Operation); + Assert.Equal(1, redirect.TargetDescriptor); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.False(redirect.IsPathRelevant); + Assert.True(redirect.IsComplete); + } + + [Fact] + public void Multiple_redirects_preserve_authored_order_and_coordinates() + { + var result = Parse("Get-Date > out.txt 2> err.txt"); + + var redirects = Assert.Single(result.Commands).Redirects; + Assert.Equal(2, redirects.Count); + Assert.Equal(new[] { 0, 1 }, redirects.Select(item => item.RedirectIndex)); + Assert.Equal( + new[] { 1, 2 }, + redirects.Select(item => item.Source.Descriptor ?? 1)); + Assert.Equal( + new[] { "C:/work/out.txt", "C:/work/err.txt" }, + redirects.Select(item => Assert.Single(item.Target.Values))); + } + + [Fact] + public void Dynamic_file_target_is_complete_but_has_unknown_value() + { + var result = Parse("Get-Date > $name"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var occurrence = Assert.Single(result.Commands); + Assert.True(occurrence.IsComplete); + var redirect = Assert.Single(occurrence.Redirects); + Assert.Equal(RedirectOperation.FileOutput, redirect.Operation); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.True(redirect.IsComplete); + } + + [Theory] + [InlineData("$null")] + [InlineData("${null}")] + public void Null_sink_is_visible_but_incomplete_without_a_public_sink_operation( + string target) + { + var result = Parse($"Get-Date > {target}"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var occurrence = Assert.Single(result.Commands); + Assert.False(occurrence.IsComplete); + var redirect = Assert.Single(occurrence.Redirects); + Assert.Equal(RedirectOperation.Unknown, redirect.Operation); + Assert.False(redirect.IsComplete); + } + + [Theory] + [InlineData("Get-Date < input.txt")] + [InlineData("Get-Date 1>&1")] + [InlineData("Get-Date 2>&3")] + [InlineData("Get-Date 2>&-")] + [InlineData("Get-Date > a > b")] + [InlineData("Get-Date > a 1> b")] + [InlineData("Get-Date 2>&1 2> b")] + [InlineData("Get-Date *> a *> b")] + public void Native_parser_errors_fail_closed(string input) + { + var result = Parse(input); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + } + + [Fact] + public void All_streams_and_numbered_stream_redirects_remain_independent() + { + var result = Parse("Get-Date *> all.txt 2> err.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var redirects = Assert.Single(result.Commands).Redirects; + Assert.Equal(2, redirects.Count); + Assert.Equal(RedirectSourceKind.PowerShellAllStreams, redirects[0].Source.Kind); + Assert.Equal(2, redirects[1].Source.Descriptor); + } + + [Fact] + public void Merge_prefix_does_not_consume_trailing_argument_text() + { + var result = Parse("Get-Date 2>&1-"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.True(Assert.Single(result.Commands).IsComplete); + Assert.Equal("-", Assert.Single(Assert.Single(result.Clauses).Args).Raw); + Assert.Equal( + RedirectOperation.DescriptorDuplicate, + Assert.Single(Assert.Single(result.Commands).Redirects).Operation); + } + + private static ParsedCommand Parse(string source) => new PwshParser( + new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + }).Parse(source); +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs index e4b331c..38e8349 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshStructuralProjectionTests.cs @@ -168,11 +168,47 @@ public void Exact_failure_rebase_crosses_decoded_child_host_boundary(string invo Assert.Equal("C:/work/out.txt", redirect.Target); } + [Theory] + [InlineData("pwsh -Command 'Get-Date'")] + [InlineData("pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA==")] + public void Joined_cwd_keeps_outer_wrapper_redirect_target_unknown( + string invocation) + { + var result = Parse($"Set-Location C:\\maybe; {invocation} > relative.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + Assert.Equal(ShellValueDomainKind.Unknown, command.WorkingDirectory.Kind); + var compatibility = Assert.Single(command.Clause.Redirects); + Assert.True(compatibility.IsDynamicSkip); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.Unknown, redirect.Target.Kind); + Assert.True(redirect.IsComplete); + Assert.True(command.IsComplete); + } + + [Theory] + [InlineData("pwsh -Command 'Get-Date'")] + [InlineData("pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA==")] + public void Success_only_cwd_resolves_outer_wrapper_redirect_target( + string invocation) + { + var result = Parse($"Set-Location C:\\maybe && {invocation} > relative.txt"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = result.Commands[1]; + Assert.Equal("C:/maybe", Assert.Single(command.WorkingDirectory.Values)); + var redirect = Assert.Single(command.Redirects); + Assert.Equal(ShellValueDomainKind.Exact, redirect.Target.Kind); + Assert.Equal("C:/maybe/relative.txt", Assert.Single(redirect.Target.Values)); + Assert.True(command.IsComplete); + } + [Theory] [InlineData("Get-Item child.txt > out.txt")] [InlineData("pwsh -Command 'Get-Item child.txt > out.txt'")] [InlineData("pwsh -EncodedCommand RwBlAHQALQBJAHQAZQBtACAAYwBoAGkAbABkAC4AdAB4AHQAIAA+ACAAbwB1AHQALgB0AHgAdAA=")] - public void Dynamic_provider_failure_promotes_only_static_compatibility_paths( + public void Dynamic_provider_failure_promotes_static_paths_and_redirect_facts( string invocation) { var result = Parse($"Set-Location Alias: || {invocation}"); @@ -194,7 +230,11 @@ public void Dynamic_provider_failure_promotes_only_static_compatibility_paths( var redirect = Assert.Single(clause.Redirects); Assert.False(redirect.IsDynamicSkip); Assert.Equal("C:/work/out.txt", redirect.Target); - Assert.False(command.IsComplete); + Assert.True(command.IsComplete); + var redirectFact = Assert.Single(command.Redirects); + Assert.Equal(RedirectOperation.FileOutput, redirectFact.Operation); + Assert.Equal("C:/work/out.txt", Assert.Single(redirectFact.Target.Values)); + Assert.True(redirectFact.IsComplete); } [Theory] @@ -438,7 +478,7 @@ public void Terminal_outer_redirect_keeps_exact_element_span_on_last_wrapped_lea Assert.Equal(2, result.Commands.Count); Assert.True(result.Commands[0].IsComplete); - Assert.False(result.Commands[1].IsComplete); + Assert.True(result.Commands[1].IsComplete); var last = result.Clauses[1]; Assert.Single(last.Redirects); var redirect = last.Elements.Last(); @@ -679,14 +719,18 @@ public void Dynamic_subexpression_location_poisons_inner_consumer_and_continuati } [Fact] - public void Redirect_subexpression_is_visible_while_outer_redirect_stays_incomplete() + public void Redirect_subexpression_is_visible_while_outer_target_stays_unknown() { var result = Parse("Get-Content > $(Join-Path C:\\temp out.txt)"); Assert.False(result.IsUnparseable); Assert.Equal(new[] { "Join-Path", "Get-Content" }, result.Commands.Select(CommandVerb)); Assert.True(result.Commands[0].IsComplete); - Assert.False(result.Commands[1].IsComplete); + Assert.True(result.Commands[1].IsComplete); + var redirectFact = Assert.Single(result.Commands[1].Redirects); + Assert.Equal(RedirectOperation.FileOutput, redirectFact.Operation); + Assert.Equal(ShellValueDomainKind.Unknown, redirectFact.Target.Kind); + Assert.True(redirectFact.IsComplete); Assert.True(Assert.Single(result.Clauses[1].Redirects).IsDynamicSkip); } diff --git a/tools/PwshCorpusTool/CorpusJson.cs b/tools/PwshCorpusTool/CorpusJson.cs index ac8a9bd..2f2727e 100644 --- a/tools/PwshCorpusTool/CorpusJson.cs +++ b/tools/PwshCorpusTool/CorpusJson.cs @@ -428,6 +428,29 @@ private static JsonArray BuildCommands( commandJson["effectiveArguments"] = effectiveArguments; commandJson["workingDirectory"] = BuildValueDomain(command.WorkingDirectory); + + if (command.Redirects.Count > 0) + { + var redirects = new JsonArray(); + foreach (var redirect in command.Redirects) + { + redirects.Add(new JsonObject + { + ["redirectIndex"] = redirect.RedirectIndex, + ["sourceKind"] = redirect.Source.Kind.ToString(), + ["sourceDescriptor"] = JsonValue.Create( + redirect.Source.Descriptor), + ["operation"] = redirect.Operation.ToString(), + ["targetDescriptor"] = JsonValue.Create( + redirect.TargetDescriptor), + ["target"] = BuildValueDomain(redirect.Target), + ["isPathRelevant"] = redirect.IsPathRelevant, + ["isComplete"] = redirect.IsComplete, + }); + } + + commandJson["redirects"] = redirects; + } } commands.Add(commandJson); diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 129ef94..2d36f47 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -375,19 +375,19 @@ private static string NestIex(string inner, int depth) "-Name is a path leaf for New-Item (§7.1)."), // ---- Redirect (§13: ≥10) ---- - S("redirect_out", "Get-Date > out.txt", "> stdout redirect (truncate)."), - E("redirect_append", "Get-Date >> log.txt", ">> stdout append."), - E("redirect_err", "Get-Process 2> err.txt", "2> stderr redirect."), - E("redirect_err_append", "Get-Process 2>> err.txt", "2>> stderr append."), - E("redirect_verbose", "Get-Date 3> verbose.txt", "3> verbose stream — maps lossily to Out."), - E("redirect_warning", "Get-Date 4> warn.txt", "4> warning stream — maps lossily to Out."), - E("redirect_debug", "Get-Date 5> debug.txt", "5> debug stream — maps lossily to Out."), - E("redirect_information", "Get-Date 6> info.txt", "6> information stream — maps lossily to Out."), - E("redirect_all", "Get-Date *> all.txt", "*> all-streams redirect — maps lossily to Out."), - E("redirect_all_append", "Get-Date *>> all.txt", "*>> all-streams append."), - E("redirect_merge_2to1", "Get-Process 2>&1", "2>&1 stream merge — target carries &1."), - E("redirect_merge_3to1", "Get-Date 3>&1", "3>&1 stream merge."), - E("redirect_null", "Get-Date > $null", "$null redirect target is the discard sink."), + V("redirect_out", "Get-Date > out.txt", "> stdout redirect (truncate)."), + V("redirect_append", "Get-Date >> log.txt", ">> stdout append."), + V("redirect_err", "Get-Process 2> err.txt", "2> stderr redirect."), + V("redirect_err_append", "Get-Process 2>> err.txt", "2>> stderr append."), + V("redirect_verbose", "Get-Date 3> verbose.txt", "3> verbose stream — maps lossily to Out."), + V("redirect_warning", "Get-Date 4> warn.txt", "4> warning stream — maps lossily to Out."), + V("redirect_debug", "Get-Date 5> debug.txt", "5> debug stream — maps lossily to Out."), + V("redirect_information", "Get-Date 6> info.txt", "6> information stream — maps lossily to Out."), + V("redirect_all", "Get-Date *> all.txt", "*> all-streams redirect — maps lossily to Out."), + V("redirect_all_append", "Get-Date *>> all.txt", "*>> all-streams append."), + V("redirect_merge_2to1", "Get-Process 2>&1", "2>&1 stream merge — target carries &1."), + V("redirect_merge_3to1", "Get-Date 3>&1", "3>&1 stream merge."), + V("redirect_null", "Get-Date > $null", "$null remains explicit but incomplete until the public model represents the discard sink."), // ---- pwsh -Command / -EncodedCommand recursion (§13: ≥15) ---- E("recursion_command_quoted", "pwsh -Command \"Remove-Item C:\\tmp\\x\"", @@ -1021,5 +1021,78 @@ private static string NestIex(string inner, int depth) VIE("v03_invoke_command_remote_positional_target_array", "Invoke-Command server1, server2 -ScriptBlock { Get-Item child.txt }; Get-Item host.txt", "PowerShell binds a positional comma continuation into the ComputerName target array before the script block."), + V("v03_redirect_multiple_explicit", + "Get-Date > out.txt 2> err.txt", + "Every file redirect retains authored order, source stream, operation, and exact target."), + V("v03_redirect_dynamic_target", + "Get-Date > $name", + "The redirect grammar is complete while its runtime target value remains Unknown."), + V("v03_redirect_merge_all_streams", + "Get-Date *>&1", + "The all-streams selector remains distinct when merged into success stream 1."), + V("v03_redirect_merge_trailing_argument", + "Get-Date 2>&1-", + "PowerShell parses the merge as 2>&1 and preserves the trailing hyphen as an argument."), + VI("v03_redirect_foreach_finite_target", + "foreach ($f in @('one.txt', 'two.txt')) { Write-Output x > $f }", + "An isolated bounded loop resolves the redirect target to a finite absolute path domain."), + E("v03_redirect_input_syntax_error", + "Get-Date < input.txt", + "PowerShell reserves input redirection for future use."), + E("v03_redirect_success_merge_source_error", + "Get-Date 1>&1", + "Success stream 1 cannot be a PowerShell merge source."), + E("v03_redirect_non_success_merge_target_error", + "Get-Date 2>&3", + "PowerShell stream merges may target only success stream 1."), + E("v03_redirect_descriptor_close_error", + "Get-Date 2>&-", + "PowerShell does not support Bash descriptor-close syntax."), + V("v03_redirect_braced_null_sink", + "Get-Date > ${null}", + "The braced null variable is the same incomplete discard sink as $null."), + V("v03_redirect_all_and_error_independent", + "Get-Date *> all.txt 2> err.txt", + "PowerShell permits all-streams and a numbered source to be redirected independently."), + E("v03_redirect_duplicate_default_error", + "Get-Date > a > b", + "PowerShell rejects a second redirect of the default output stream."), + E("v03_redirect_duplicate_default_explicit_error", + "Get-Date > a 1> b", + "Default output and explicit stream 1 are the same redirect source."), + E("v03_redirect_duplicate_merge_source_error", + "Get-Date 2>&1 2> b", + "A merge and file redirect cannot both consume error stream 2."), + E("v03_redirect_duplicate_all_streams_error", + "Get-Date *> a *> b", + "PowerShell rejects a second all-streams redirect."), + V("v03_redirect_wrapper_joined_cwd", + "Set-Location C:\\maybe; pwsh -Command 'Get-Date' > relative.txt", + "A fallible cwd transition keeps the outer wrapper redirect target Unknown."), + V("v03_redirect_encoded_wrapper_joined_cwd", + "Set-Location C:\\maybe; pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA== > relative.txt", + "Encoded child syntax retains outer redirect provenance across a joined cwd."), + VI("v03_redirect_unreachable_wrapper_binding", + "foreach ($f in @()) { pwsh -Command 'Get-Date' > $f }", + "An unreachable wrapper redirect stays visible, Unknown, and incomplete rather than borrowing a loop value."), + VI("v03_redirect_unreachable_relative_target", + "foreach ($x in @()) { Write-Output x > relative.txt }", + "An unreachable relative redirect cannot retain the parser working directory as an exact target."), + VI("v03_redirect_unreachable_absolute_target", + "foreach ($x in @()) { Write-Output x > C:\\fixed.txt }", + "A cwd-independent absolute redirect target remains exact even on an unreachable incomplete occurrence."), + VI("v03_redirect_wrapper_parent_binding", + "foreach ($f in @('one.txt', 'two.txt')) { pwsh -Command 'Get-Date' > $f }", + "The parent shell resolves an outer wrapper redirect from its bounded loop binding before child launch."), + VI("v03_redirect_encoded_wrapper_parent_binding", + "foreach ($f in @('one.txt', 'two.txt')) { pwsh -EncodedCommand RwBlAHQALQBEAGEAdABlAA== > $f }", + "Encoded child syntax does not erase the parent-owned finite redirect target domain."), + VI("v03_redirect_nested_wrapper_parent_binding", + "foreach ($f in @('one.txt', 'two.txt')) { pwsh -Command \"pwsh -Command 'Get-Date'\" > $f }", + "A redirect outside nested command-string wrappers retains its outermost parent binding."), + VI("v03_redirect_nested_encoded_wrapper_parent_binding", + "foreach ($f in @('one.txt', 'two.txt')) { pwsh -EncodedCommand " + + B64("pwsh -EncodedCommand " + B64("Get-Date")) + " > $f }", + "Nested encoded wrappers retain the invocation scope that owns the outer redirect."), }; }