From 3c1b8968a64bdbd14528a86dfdf1868eecf95d09 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 14:13:31 +0000 Subject: [PATCH] Add PowerShell initial state contract --- IMPLEMENTATION_PLAN.md | 13 +- README.md | 4 +- SPEC.POWERSHELL.md | 50 +++- SPEC.md | 51 +++- docs/CONSUMER_GUIDE.md | 28 ++ .../v0-3-structured-shell-analysis/design.md | 26 ++ .../specs/bounded-shell-analysis/spec.md | 52 ++++ .../v0-3-structured-shell-analysis/tasks.md | 5 + .../Pwsh/Parsing/PwshCommandParser.cs | 21 +- .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 1 + src/ShellSyntaxTree/PwshParser.cs | 2 +- src/ShellSyntaxTree/PwshParserOptions.cs | 29 +- .../DesignCorpus/V03DesignCorpusTests.cs | 51 +++- .../DesignCorpus/v0.3/powershell.json | 283 ++++++++++++++++++ .../Parsing/PwshCommandParserTests.cs | 27 ++ .../Parsing/PwshForEachStructuralTests.cs | 2 + .../PublicApiSnapshotTests.cs | 18 +- 17 files changed, 621 insertions(+), 42 deletions(-) diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index dfc7be9..7450fb0 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -315,10 +315,15 @@ priorities. initial-runspace contract and wrapper-state metadata: ambient typed, read-only, scoped, alias, function, and module state can change binding assignment and command resolution, while child hosts inherit no fresh - state guarantee unless their own invocation proves it. Expand the design - corpus for cardinality, mutation, separators, wrappers, redirects, and - transition caps before tasks 7.3-7.7. The simple-command slice is delivered for - ordinary, adjacent, quoted, here-string, redirect, standalone, + state guarantee unless their own invocation proves it. The additive + `PwshInitialStateMode` API and safe-default contract are now locked; + `-NoProfile -NonInteractive` alone is explicitly insufficient without a + controlled startup, inherited environment, and module baseline. Design + cases select the mode individually and pin default `Unknown`. Tasks + 7.3-7.4 must consume the contract rather than inferring isolation. Expand the + design corpus for cardinality, mutation, separators, wrappers, redirects, + and transition caps before tasks 7.3-7.7. The simple-command slice is + delivered for ordinary, adjacent, quoted, here-string, redirect, standalone, call-operator, dynamic-identity, and host-wrapper positions, with current-scope state propagation and bounded expression rejection pinned by the 361-entry executable corpus. diff --git a/README.md b/README.md index bfd6f91..dd6863e 100644 --- a/README.md +++ b/README.md @@ -117,8 +117,8 @@ public sealed class BashParser : IShellParser { /* … */ } public sealed class PwshParser : IShellParser { /* … */ } // v0.2.0 public abstract record ShellParserOptions { /* HomeDirectory, WorkingDirectory */ } -public sealed record BashParserOptions : ShellParserOptions; -public sealed record PwshParserOptions : ShellParserOptions; +public sealed record BashParserOptions : ShellParserOptions; // InitialStateMode +public sealed record PwshParserOptions : ShellParserOptions; // InitialStateMode public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ } public sealed record Clause { /* Operator, Verb, Args, Redirects, Elements, IsSubshell, IsCommandStringWrapped */ } diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 42e6973..940ad5b 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -115,11 +115,18 @@ public abstract record ShellParserOptions /// = ... }` still compiles. public sealed record BashParserOptions : ShellParserOptions; -/// Configuration knobs for PwshParser. Empty in v0.2.0 — alias -/// resolution is unconditional (§6.3) and the resolver knobs live on the -/// shared ShellParserOptions base. Kept as a distinct type so a future -/// PowerShell-only knob is an additive change, not a new type. -public sealed record PwshParserOptions : ShellParserOptions; +/// Declares which ambient PowerShell runspace facts the caller can prove. +public enum PwshInitialStateMode +{ + Unknown, + IsolatedNonInteractiveNoProfile, +} + +/// Configuration knobs for PwshParser. +public sealed record PwshParserOptions : ShellParserOptions +{ + public PwshInitialStateMode InitialStateMode { get; init; } +} /// PowerShell implementation of IShellParser. public sealed class PwshParser : IShellParser @@ -407,6 +414,39 @@ after the structural grammar proves that the `ScriptBlock` token is the body of a recognized statement. An ordinary script-block argument remains one opaque `DynamicSkip` value and does not invent child execution. +Publishing those exact or finite values also requires +`PwshInitialStateMode.IsolatedNonInteractiveNoProfile`. The default `Unknown` +mode still exposes the complete supported structure, but loop-body occurrences +whose safety depends on the binding remain incomplete. The isolated mode is a +caller assertion that the complete source runs in a newly spawned, +non-interactive, no-profile PowerShell process with no reused or +uncontrolled caller-initialized runspace state. Startup configuration and the +inherited environment must also be controlled: module auto-loading is disabled, +or available modules and module search paths are pinned to the same reviewed +baseline used by policy. `-NoProfile -NonInteractive` alone is insufficient. +A fixed bootstrap may establish those constraints only when it cannot define +or mutate loop-bound variables or policy-relevant command identities. The mode +does not permit assumptions about an interactive session, a runspace pool, +profiles, startup scripts, or uncontrolled ambient variables, aliases, +functions, and modules. + +Even under that assertion, only ordinary unscoped binding names that do not +case-insensitively collide with PowerShell's automatic, constant, or read-only +variables are eligible. Scoped/provider bindings such as `$global:x`, +`$script:x`, `$private:x`, and `$env:X` fail the loop region closed. A typed, +validated, constant, or read-only ambient binding therefore cannot coerce, +reject, or otherwise alter a value the analyzer presents as an exact string. + +Parenthesized groups, `$()`, and static `Invoke-Expression` execute in the +current runspace and share supported binding, command-resolution, and location +state. Decoded `pwsh -Command` and `pwsh -EncodedCommand` payloads run in child +hosts and do not inherit the parent's fresh-state assertion unless their own +invocation independently proves the complete constrained-host contract; host +flags alone do not prove the launch environment or module baseline. +Recognized variable, alias, function, or module mutation invalidates later +proofs in every observing scope; cwd-only mutation retains the independent +initial-state assertion. + A completely delimited `$()` used as an ordinary word, dynamic command identity after `&`, redirect value, foreach expression, double-quoted interpolation, or expandable here-string is recursively parsed as a command substitution. Its diff --git a/SPEC.md b/SPEC.md index 5e53307..537e626 100644 --- a/SPEC.md +++ b/SPEC.md @@ -122,9 +122,18 @@ public sealed record BashParserOptions : ShellParserOptions public BashInitialStateMode InitialStateMode { get; init; } } -/// Configuration knobs for PwshParser (v0.2.0). Empty — the -/// resolver knobs live on ShellParserOptions. -public sealed record PwshParserOptions : ShellParserOptions; +/// Declares which ambient PowerShell runspace facts the caller can prove. +public enum PwshInitialStateMode +{ + Unknown, + IsolatedNonInteractiveNoProfile, +} + +/// Configuration knobs for PwshParser. +public sealed record PwshParserOptions : ShellParserOptions +{ + public PwshInitialStateMode InitialStateMode { get; init; } +} // The pre-v0.2.0 BashParserOptions body, now hoisted onto ShellParserOptions: public abstract record ShellParserOptions @@ -270,6 +279,42 @@ as `RANDOM`, `LINENO`, `HOME`, `PATH`, `CDPATH`, and `IFS`. The boundary is extend-only: a later version may add a proved variable-state model or additional explicitly reviewed ordinary names. +`PwshInitialStateMode.Unknown` is likewise the safe default. In this mode the +parser may expose `foreach` structure and commands, but it does not publish an +exact or finite loop-binding proof. Ambient PowerShell variables can be typed, +read-only, constant, scoped, or validated, and ambient aliases, functions, and +modules can change command resolution. Treating a loop assignment as a plain +string assignment without excluding those facts would be unsound. + +`PwshInitialStateMode.IsolatedNonInteractiveNoProfile` is an explicit caller +assertion that the complete source is executed by a newly spawned, +non-interactive PowerShell process with profiles disabled and without a reused +or uncontrolled caller-initialized runspace. The caller must also control +startup configuration and the inherited environment: module auto-loading must +be disabled, or the available modules and module search paths must be pinned to +the same reviewed baseline used by policy. `-NoProfile -NonInteractive` alone +does not establish this contract. A fixed bootstrap may establish these +constraints only when it cannot define or mutate loop-bound variables or +policy-relevant command identities. + +The mode does not erase PowerShell's built-in automatic variables. Exact and +finite binding proofs remain limited to +ordinary unscoped variable names that do not collide, case-insensitively, with +automatic, constant, or read-only bindings known to the supported PowerShell +runtime. Scoped/provider forms such as `$global:x`, `$script:x`, and `$env:X` +are outside the bounded loop-binding grammar. + +The assertion applies only to the host that the caller actually constrains. +Current-runspace regions such as `( ... )`, `$()`, and a static +`Invoke-Expression` payload share supported variable, command-resolution, and +location state. A decoded `pwsh -Command` or `pwsh -EncodedCommand` child does +not inherit the assertion unless its own invocation contract independently +proves the complete constrained-host environment, not merely `-NoProfile`. +Recognized mutation of variables, +aliases, functions, or modules invalidates later proofs wherever PowerShell +scope rules make the mutation observable. Cwd-only state changes retain the +independent initial-runspace assertion. + For a successful result, every authored simple command appears once in `Syntax`, once in `Commands`, and once in `Clauses`, with all three projections referencing the identical in-memory `Clause` instance. Serialization is not diff --git a/docs/CONSUMER_GUIDE.md b/docs/CONSUMER_GUIDE.md index 3e25751..d583391 100644 --- a/docs/CONSUMER_GUIDE.md +++ b/docs/CONSUMER_GUIDE.md @@ -198,6 +198,34 @@ outside the first bounded scalar grammar. The parser also downgrades a decoded `export`; resolver-only option cloning for an exact cwd retains the independent variable-state assertion. +PowerShell `foreach` value proofs require the parallel but shell-specific +assertion. `PwshInitialStateMode.Unknown` is the safe default: the parser can +still expose supported loop structure, but ambient typed, validated, +read-only, scoped, alias, function, and module state prevents a closed-world +binding proof. Select `IsolatedNonInteractiveNoProfile` only when the caller +executes the complete source in a newly spawned noninteractive PowerShell +process with profiles disabled and no reused or uncontrolled caller-initialized +runspace. The launch must also disable module auto-loading or pin available +modules and module search paths to the same reviewed baseline used by policy. +A fixed bootstrap may establish those constraints only if it cannot define or +mutate loop-bound variables or policy-relevant command identities: + +```csharp +var parser = new PwshParser(new PwshParserOptions +{ + WorkingDirectory = workingDirectory, + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, +}); +``` + +The assertion does not automatically cross `pwsh -Command` or +`pwsh -EncodedCommand`; a child host needs its own independently proved launch +contract. By contrast, `( ... )`, `$()`, and static `Invoke-Expression` share +the current runspace and its mutations. Never select isolated mode for an +interactive session or runspace pool merely to suppress approval prompts. +`-NoProfile -NonInteractive` alone does not prove the inherited environment, +startup configuration, or module baseline. + Heredoc and Bash here-string bodies are stdin data, not implicit child commands or filesystem paths. Authorize any command substitutions surfaced from an expanding heredoc as normal occurrences, then let executable-specific policy diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 43ee66b..6d5daba 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -423,6 +423,32 @@ later scope that can observe it. A decoded `bash -c` after `export` therefore enters with `Unknown` initial variable state, while a cwd-only transfer retains the caller's variable-state assertion. +PowerShell needs the same explicit boundary for different reasons. +`PwshParserOptions.InitialStateMode` defaults to `Unknown`, which permits +structural discovery but withholds exact or finite `foreach` binding proofs. +`IsolatedNonInteractiveNoProfile` asserts that the complete source executes in +a newly spawned noninteractive, no-profile PowerShell process rather than an +interactive, pooled, reused, profile-initialized, or uncontrolled caller-initialized +runspace. The caller also controls startup configuration and the inherited +environment: module auto-loading is disabled, or available modules and module +search paths are pinned to the policy's reviewed baseline. A fixed bootstrap +may establish those constraints only when it cannot define or mutate +loop-bound variables or policy-relevant command identities. +`-NoProfile -NonInteractive` alone is not proof of that environment. Ambient +PowerShell bindings can be typed, validated, constant, +read-only, or scoped; aliases, functions, and modules can independently change +command identity. Syntax alone cannot erase any of those facts. + +The positive binding grammar therefore accepts only ordinary unscoped names +that do not case-insensitively collide with automatic, constant, or read-only +variables known to the supported runtime. Current-runspace groups, `$()`, and +static `Invoke-Expression` share supported binding, command-resolution, and cwd +state. A decoded child `pwsh` host starts at `Unknown` unless its own invocation +independently proves the complete constrained-host contract. Recognized +variable, alias, +function, or module mutation invalidates every later observing proof; a cwd-only +transfer preserves the independent initial-runspace assertion. + Effective values are shell facts, not executable semantics. The analysis must preserve both the authored shell classification and each proved effective value. PowerShell does not retroactively turn a string value such as `-Force` 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 ba8cb35..6518809 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 @@ -168,6 +168,58 @@ the independently proved variable-state mode. - **THEN** the decoded child enters with unknown initial variable state - **THEN** the complete result is unparseable rather than publishing an isolated scalar proof +### Requirement: PowerShell loop proofs require an explicit initial-runspace contract +`PwshParserOptions.InitialStateMode` SHALL default to `Unknown`. In that mode, +the parser MAY expose supported `foreach` structure and command occurrences, +but SHALL NOT publish an exact or finite loop-binding proof whose semantics +could be changed by ambient runspace state. + +`IsolatedNonInteractiveNoProfile` SHALL be an explicit caller assertion that +the complete source runs in a newly spawned noninteractive PowerShell process, +profiles are disabled, and the runspace has not been reused or initialized by +uncontrolled caller variables, aliases, functions, or modules. The caller SHALL +also control startup configuration and the inherited environment. Module +auto-loading SHALL be disabled, or available modules and module search paths +SHALL be pinned to the same reviewed baseline used by policy. A fixed bootstrap +MAY establish those constraints only when it cannot define or mutate loop-bound +variables or policy-relevant command identities. `-NoProfile -NonInteractive` +alone SHALL NOT satisfy the contract. Exact and finite +binding analysis SHALL remain limited to ordinary unscoped names that do not +case-insensitively collide with automatic, constant, or read-only variables. +Scoped/provider binding forms SHALL fail closed. + +Current-runspace groups, `$()`, and static `Invoke-Expression` payloads SHALL +share supported binding, command-resolution, and cwd state. A decoded child +PowerShell host SHALL NOT inherit the parent's fresh-state assertion unless +that invocation independently proves the complete constrained-host contract. +Host flags alone SHALL NOT prove the launch environment or module baseline. +Recognized variable, alias, function, or module mutation SHALL invalidate later proofs in +every observing scope; cwd-only mutation SHALL retain the independent +initial-state assertion. + +#### Scenario: Unknown ambient PowerShell state withholds a finite proof +- **WHEN** default-mode PowerShell parses `foreach ($f in @('a','b')) { Remove-Item -LiteralPath $f }` +- **THEN** the loop structure and body command may remain visible +- **THEN** the body occurrence is incomplete rather than assuming `$f` is an ordinary string binding + +#### Scenario: Isolated no-profile runspace permits an ordinary binding proof +- **WHEN** the caller selects `IsolatedNonInteractiveNoProfile` for a newly spawned constrained host and parses `foreach ($f in @('a','b')) { Write-Output $f }` +- **THEN** the bounded analyzer may publish the finite string domain `a`, `b` + +#### Scenario: Typed or read-only ambient binding is not erased by syntax +- **WHEN** a reused runspace already contains `[int]$f` or a read-only `$f` and parses a loop that assigns string values +- **THEN** default-mode analysis does not claim the authored strings are the effective loop values +- **THEN** selecting isolated mode for that reused runspace would violate the caller contract + +#### Scenario: Child host does not inherit the parent's assertion +- **WHEN** isolated-mode PowerShell parses a supported `pwsh -NoProfile -Command` child containing a `foreach` +- **THEN** the child receives `Unknown` initial state unless the child invocation independently proves the complete constrained-host environment +- **THEN** `-NoProfile` by itself does not prove the inherited environment or module baseline + +#### Scenario: Current-runspace evaluation shares state +- **WHEN** a supported `$()` or static `Invoke-Expression` region mutates a loop-relevant binding or command-resolution fact +- **THEN** every later observing occurrence is joined with or downgraded to the resulting conservative state + ### Requirement: Shell values use explicit proof domains The analysis SHALL classify a policy-relevant shell value as exact, finite, bounded symbolic pattern, or unknown, and SHALL NOT present a weaker proof as a diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 108addc..764765c 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -128,6 +128,11 @@ iterator/body state or command-resolution mutation and dynamic invocation fail atomically. Current-scope continuations after a loop remain incomplete; isolated child-host loops do not taint their outer continuation. +- [x] 7.2a Add the explicit `PwshInitialStateMode` contract and safe default + before value analysis. Lock the constrained noninteractive no-profile host + and module baseline, current-runspace sharing, child-host noninheritance, + mutation invalidation, and ambient typed/read-only binding hazards in the + canonical specs and case-specific design corpus. - [ ] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings. - [ ] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics. - [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries. diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index af6d13f..2ee93f1 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -531,7 +531,7 @@ private static BuildResult BuildSegment( if (classified.Kind == PwshCommandKind.PwshInvocation) { var recursion = TryRecurseIntoPwsh( - body, start, classified, source, baseOptions, effectiveOptions, + body, start, classified, source, effectiveOptions, workingDirectoryUnknown, recursionDepth, structuralDepth, segment, markWrapped, out var recursionResult); if (recursion) @@ -1956,7 +1956,7 @@ private static bool IsInvokeExpressionName(string identity) private static bool TryRecurseIntoPwsh( List body, int start, ClassifiedVerb verb, string source, - PwshParserOptions options, PwshParserOptions redirectOptions, + PwshParserOptions redirectOptions, bool workingDirectoryUnknown, int recursionDepth, int structuralDepth, @@ -2078,9 +2078,22 @@ private static bool TryRecurseIntoPwsh( return true; } + // A child process does not inherit a caller assertion about the + // parent runspace. A later host-option proof may opt the child in. + var childOptions = redirectOptions with + { + InitialStateMode = PwshInitialStateMode.Unknown, + }; + PwshSetLocationContext? childLocation = null; + if (workingDirectoryUnknown) + { + childLocation = new PwshSetLocationContext(); + childLocation.SetDynamic(); + } + var innerParsed = ParseInternal( - inner, options, recursionDepth + 1, structuralDepth + 1, markWrapped: true, - sharedLocation: null); + inner, childOptions, recursionDepth + 1, structuralDepth + 1, markWrapped: true, + sharedLocation: childLocation); if (innerParsed.IsUnparseable) { result = BuildResult.Fail(innerParsed.UnparseableReason); diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs index 0d71bc0..b7c26ce 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs @@ -463,6 +463,7 @@ private bool TryParseCommand( { HomeDirectory = _options.HomeDirectory, WorkingDirectory = _attribution.ResolvedCwd, + InitialStateMode = _options.InitialStateMode, }; } else if (_attribution.IsDynamic) diff --git a/src/ShellSyntaxTree/PwshParser.cs b/src/ShellSyntaxTree/PwshParser.cs index 23ebf07..fcddf04 100644 --- a/src/ShellSyntaxTree/PwshParser.cs +++ b/src/ShellSyntaxTree/PwshParser.cs @@ -26,7 +26,7 @@ public PwshParser() : this(new PwshParserOptions()) /// /// Create a parser with the supplied options. /// - /// Resolver knobs (home / working directory). + /// Resolver and initial-runspace contract options. public PwshParser(PwshParserOptions options) { if (options is null) diff --git a/src/ShellSyntaxTree/PwshParserOptions.cs b/src/ShellSyntaxTree/PwshParserOptions.cs index 6203e33..7a6ee63 100644 --- a/src/ShellSyntaxTree/PwshParserOptions.cs +++ b/src/ShellSyntaxTree/PwshParserOptions.cs @@ -5,11 +5,28 @@ // ----------------------------------------------------------------------- namespace ShellSyntaxTree; +/// Declares which ambient PowerShell runspace facts the caller can prove. +public enum PwshInitialStateMode +{ + /// No safe assumption is made about ambient PowerShell runspace state. + Unknown, + + /// + /// The source runs in a new non-interactive PowerShell process with + /// profiles, startup state, and module discovery constrained as specified. + /// + IsolatedNonInteractiveNoProfile, +} + /// -/// Configuration knobs for . Empty in v0.2.0 — -/// alias resolution is unconditional (SPEC.POWERSHELL.md §6.3) and the -/// resolver knobs live on the shared base. -/// Kept as a distinct type so a future PowerShell-only knob is an additive -/// change, not a new type. +/// Configuration knobs for . The resolver knobs live +/// on the shared base. /// -public sealed record PwshParserOptions : ShellParserOptions; +public sealed record PwshParserOptions : ShellParserOptions +{ + /// + /// Gets the caller-proved initial PowerShell runspace-state contract. The + /// default fails bounded loop-variable analysis closed. + /// + public PwshInitialStateMode InitialStateMode { get; init; } +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs index bc076dc..b7c824a 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs @@ -54,6 +54,20 @@ public void Design_corpus_is_well_formed_and_balanced_across_shells() value.Kind == DesignValueKind.FiniteSet && value.Values.Count == MaxValueCandidates), $"{file.Shell}: no case exercises the finite candidate cap."); + + if (file.Shell == DesignShell.PowerShell) + { + Assert.Contains(file.Cases, + designCase => designCase.PowerShellInitialStateMode is null); + Assert.Contains(file.Cases, + designCase => designCase.PowerShellInitialStateMode + == PwshInitialStateMode.IsolatedNonInteractiveNoProfile); + } + else + { + Assert.All(file.Cases, + designCase => Assert.Null(designCase.PowerShellInitialStateMode)); + } } } @@ -62,10 +76,9 @@ public void Compatibility_projection_matches_current_or_promoted_expectations() { foreach (var file in LoadFiles()) { - var parser = CreateParser(file.Shell); foreach (var designCase in file.Cases) { - var actual = parser.Parse(designCase.Input); + var actual = CreateParser(file.Shell, designCase).Parse(designCase.Input); var expectedIsUnparseable = designCase.CompatibilityProjectionLanded ? designCase.Desired.IsUnparseable : designCase.Current.IsUnparseable; @@ -399,21 +412,25 @@ private static void ValidateWorkingDirectory( Assert.Null(workingDirectory.Value); } - private static IShellParser CreateParser(DesignShell shell) => shell switch - { - DesignShell.Bash => new BashParser(new BashParserOptions + private static IShellParser CreateParser( + DesignShell shell, + V03DesignCase designCase) => shell switch { - HomeDirectory = "/home/test", - WorkingDirectory = "/work", - InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, - }), - DesignShell.PowerShell => new PwshParser(new PwshParserOptions - { - HomeDirectory = "C:/Users/user", - WorkingDirectory = "C:/work", - }), - _ => throw new InvalidOperationException($"Unsupported design-corpus shell '{shell}'."), - }; + DesignShell.Bash => new BashParser(new BashParserOptions + { + HomeDirectory = "/home/test", + WorkingDirectory = "/work", + InitialStateMode = BashInitialStateMode.IsolatedNonInteractive, + }), + DesignShell.PowerShell => new PwshParser(new PwshParserOptions + { + HomeDirectory = "C:/Users/user", + WorkingDirectory = "C:/work", + InitialStateMode = designCase.PowerShellInitialStateMode + ?? PwshInitialStateMode.Unknown, + }), + _ => throw new InvalidOperationException($"Unsupported design-corpus shell '{shell}'."), + }; private static IReadOnlyList LoadFiles() { @@ -446,6 +463,8 @@ public sealed record V03DesignCase public bool CompatibilityProjectionLanded { get; init; } + public PwshInitialStateMode? PowerShellInitialStateMode { get; init; } + public CurrentBehaviorExpectation Current { get; init; } = new(); public DesiredDesignExpectation Desired { get; init; } = new(); diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index 8602d40..bcab218 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -215,6 +215,7 @@ }, { "id": "pwsh-foreach-literal-array", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Finite literal foreach binding", "input": "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }", @@ -243,6 +244,7 @@ }, { "id": "pwsh-foreach-pipeline-iterator", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Iterator command produces unknown PowerShell objects", "input": "foreach ($f in Get-ChildItem C:\\input) { Remove-Item -LiteralPath $f }", @@ -275,6 +277,7 @@ }, { "id": "pwsh-foreach-subexpression-iterator", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Subexpression iterator preserves nearest substitution role and outer iterator ancestry", "input": "foreach ($f in $(Get-ChildItem C:\\input)) { Remove-Item -LiteralPath $f }", @@ -309,6 +312,7 @@ }, { "id": "pwsh-foreach-cmdlet-parameter-like-value", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Expanded cmdlet value is not a syntactic parameter token", "input": "foreach ($f in @('-Force', 'a.txt')) { Remove-Item $f }", @@ -338,6 +342,7 @@ }, { "id": "pwsh-foreach-native-option-like-value", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Finite value can affect native option parsing", "input": "foreach ($f in @('-n', 'file.txt')) { tool $f }", @@ -366,6 +371,7 @@ }, { "id": "pwsh-nested-foreach", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Nested finite domains and structural ancestry", "input": "foreach ($d in @('a', 'b')) { foreach ($f in @('x', 'y')) { Write-Output \"$d/$f\" } }", @@ -396,6 +402,7 @@ }, { "id": "pwsh-foreach-body-pipeline", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Pipeline roles inside foreach ancestry", "input": "foreach ($f in @('a', 'b')) { Write-Output $f | Sort-Object }", @@ -555,6 +562,7 @@ }, { "id": "pwsh-foreach-dynamic-command-identity", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "concern": "Dynamic invocation in a bounded loop body", "input": "foreach ($f in @('a', 'b')) { & $exe $f }", "current": { "isUnparseable": true, "reasonContains": "dynamic invocation" }, @@ -582,6 +590,7 @@ }, { "id": "pwsh-foreach-candidate-cap-32", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Finite candidate domain at the fixed cap", "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32')) { Write-Output $f }", @@ -610,6 +619,7 @@ }, { "id": "pwsh-foreach-candidate-overflow-33", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "compatibilityProjectionLanded": true, "concern": "Candidate overflow collapses instead of truncating", "input": "foreach ($f in @('v01','v02','v03','v04','v05','v06','v07','v08','v09','v10','v11','v12','v13','v14','v15','v16','v17','v18','v19','v20','v21','v22','v23','v24','v25','v26','v27','v28','v29','v30','v31','v32','v33')) { Write-Output $f }", @@ -636,6 +646,278 @@ "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] } }, + { + "id": "pwsh-foreach-default-initial-state-withholds-binding", + "compatibilityProjectionLanded": true, + "concern": "The safe default exposes structure without assuming an ambient variable is an ordinary string binding", + "input": "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ + "authoredVerb": "Remove-Item", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": false, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": true } + ] + }], + "compatibility": { "verbs": ["Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + }, + "notes": "No powerShellInitialStateMode field means PwshInitialStateMode.Unknown." + }, + { + "id": "pwsh-foreach-literal-scalar", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "A single literal iterable produces one exact string binding", + "input": "foreach ($f in 'a.txt') { Remove-Item -LiteralPath $f }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ + "authoredVerb": "Remove-Item", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$f", "kind": "Exact", "values": ["a.txt"], "isPolicySensitive": true } + ] + }], + "compatibility": { "verbs": ["Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "pwsh-foreach-empty-iterable-preserves-cwd", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "concern": "A proved empty array performs no body state transition", + "input": "foreach ($f in @()) { Set-Location C:\\tmp }; Get-Location", + "current": { "isUnparseable": true, "reasonContains": "state mutation" }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "list", "kind": "CommandList", "parent": "root", "slot": "Statement" }, + { "id": "loop", "kind": "ForEach", "parent": "list", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "set", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 }, + { "id": "get", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Set-Location", "immediateRole": "LoopBody", "ancestry": ["root", "list", "loop", "body"], "isComplete": true, "workingDirectory": { "kind": "Unknown" } }, + { "authoredVerb": "Get-Location", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "workingDirectory": { "kind": "Exact", "value": "C:/work" } } + ], + "compatibility": { "verbs": ["Set-Location", "Get-Location"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "StateJoinConservative"] + }, + "notes": "The body remains structurally visible, but the Never iteration plan contributes no location mutation." + }, + { + "id": "pwsh-foreach-duplicate-order-controls-final-binding", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Ordered visits preserve duplicates independently of the public finite set", + "input": "foreach ($f in @('a','b','a')) { Write-Output $f }; Write-Output $f", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "list", "kind": "CommandList", "parent": "root", "slot": "Statement" }, + { "id": "loop", "kind": "ForEach", "parent": "list", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "inside", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 }, + { "id": "after", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "list", "loop", "body"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "FiniteSet", "values": ["a", "b"], "isPolicySensitive": false }] }, + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "Exact", "values": ["a"], "isPolicySensitive": false }] } + ], + "compatibility": { "verbs": ["Write-Output", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "StateJoinConservative"] + } + }, + { + "id": "pwsh-foreach-binding-is-case-insensitive", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "PowerShell variable lookup uses one case-insensitive binding slot", + "input": "foreach ($f in @('a')) { Write-Output $F }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "loop", "body"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$F", "kind": "Exact", "values": ["a"], "isPolicySensitive": false }] }], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "pwsh-nested-same-name-foreach-overwrites-binding", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Nested same-name foreach assignment is persistent rather than lexical shadowing", + "input": "foreach ($f in @('outer')) { foreach ($f in @('inner')) { Write-Output $f }; Write-Output $f }; Write-Output $f", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "list", "kind": "CommandList", "parent": "root", "slot": "Statement" }, + { "id": "outer", "kind": "ForEach", "parent": "list", "slot": "Statement", "binding": "f" }, + { "id": "outerBody", "kind": "Block", "parent": "outer", "slot": "Body" }, + { "id": "inner", "kind": "ForEach", "parent": "outerBody", "slot": "Statement", "binding": "f" }, + { "id": "innerBody", "kind": "Block", "parent": "inner", "slot": "Body" }, + { "id": "innerWrite", "kind": "SimpleCommand", "parent": "innerBody", "slot": "Statement", "commandIndex": 0 }, + { "id": "outerWrite", "kind": "SimpleCommand", "parent": "outerBody", "slot": "Statement", "commandIndex": 1 }, + { "id": "afterWrite", "kind": "SimpleCommand", "parent": "list", "slot": "Statement", "commandIndex": 2 } + ], + "commands": [ + { "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "list", "outer", "outerBody", "inner", "innerBody"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "Exact", "values": ["inner"], "isPolicySensitive": false }] }, + { "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "list", "outer", "outerBody"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "Exact", "values": ["inner"], "isPolicySensitive": false }] }, + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root", "list"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "Exact", "values": ["inner"], "isPolicySensitive": false }] } + ], + "compatibility": { "verbs": ["Write-Output", "Write-Output", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "StateJoinConservative"] + } + }, + { + "id": "pwsh-foreach-array-containing-null", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "An array containing null visits once but does not prove a string argument", + "input": "foreach ($f in @($null)) { Write-Output $f }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "loop", "body"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": false }] }], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + }, + "notes": "This differs from @(), which performs no visit, and from scalar $null, whose enumeration is empty." + }, + { + "id": "pwsh-foreach-binding-used-as-redirect-target", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Loop values are resolved in redirect context for every candidate", + "input": "foreach ($f in @('a.log','b.log')) { Write-Output payload > $f }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ + "authoredVerb": "Write-Output", + "immediateRole": "LoopBody", + "ancestry": ["root", "loop", "body"], + "isComplete": true, + "redirects": [{ "operation": "FileOutput", "sourceDescriptor": 1, "target": { "sourceElement": "$f", "kind": "FiniteSet", "values": ["C:/work/a.log", "C:/work/b.log"], "isPolicySensitive": true }, "isPathRelevant": true, "isComplete": true }] + }], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated"] + } + }, + { + "id": "pwsh-foreach-current-runspace-invoke-expression", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "Static Invoke-Expression shares the loop binding and current runspace", + "input": "foreach ($f in @('a','b')) { Invoke-Expression 'Write-Output $f' }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "loop", "kind": "ForEach", "parent": "root", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "iex", "kind": "Group", "parent": "body", "slot": "Statement" }, + { "id": "iexBody", "kind": "Block", "parent": "iex", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "iexBody", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "loop", "body", "iex", "iexBody"], "isComplete": true, "effectiveValues": [{ "sourceElement": "$f", "kind": "FiniteSet", "values": ["a", "b"], "isPolicySensitive": false }] }], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "EveryFiniteCandidateEvaluated", "StateJoinConservative"] + } + }, + { + "id": "pwsh-foreach-child-host-does-not-inherit-state-mode", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, + "concern": "A decoded child host needs an independently proved initial-runspace contract", + "input": "pwsh -NoProfile -NonInteractive -Command 'foreach ($f in @(\"a\")) { Write-Output $f }'", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "Group", "parent": "root", "slot": "Statement" }, + { "id": "hostBody", "kind": "Block", "parent": "host", "slot": "Body" }, + { "id": "loop", "kind": "ForEach", "parent": "hostBody", "slot": "Statement", "binding": "f" }, + { "id": "body", "kind": "Block", "parent": "loop", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [{ "authoredVerb": "Write-Output", "immediateRole": "LoopBody", "ancestry": ["root", "host", "hostBody", "loop", "body"], "isComplete": false, "effectiveValues": [{ "sourceElement": "$f", "kind": "Unknown", "isPolicySensitive": false }] }], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + }, + "notes": "The child starts Unknown until host-option and launch-environment proof is implemented explicitly." + }, + { + "id": "pwsh-foreach-automatic-variable-collision", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "concern": "Fresh process mode does not make automatic read-only variables writable", + "input": "foreach ($HOME in @('x')) { Write-Output $HOME }", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-foreach-transition-budget-overflow", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "concern": "Nested concrete visits fail atomically above the parse-wide transition budget", + "input": "foreach ($a in @('a01','a02','a03','a04','a05','a06','a07','a08','a09','a10','a11','a12','a13','a14','a15','a16','a17')) { foreach ($b in @('b01','b02','b03','b04','b05','b06','b07','b08','b09','b10','b11','b12','b13','b14','b15','b16','b17')) { foreach ($c in @('c01','c02','c03','c04','c05','c06','c07','c08','c09','c10','c11','c12','c13','c14','c15')) { Write-Output \"$a$b$c\" } } }", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + }, + "notes": "17 x 17 x 15 concrete innermost visits exceed the shared 4096-transition budget." + }, { "id": "pwsh-static-stream-merge", "concern": "Static PowerShell stream merge", @@ -680,6 +962,7 @@ }, { "id": "pwsh-foreach-location-failure-aware-join", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", "concern": "Loop exit joins failure and divergent successful location state", "input": "foreach ($d in @('C:\\a', 'C:\\b')) { Set-Location $d }; Get-ChildItem file.txt", "current": { "isUnparseable": true, "reasonContains": "state mutation" }, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index 0217106..25f6244 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -935,6 +935,33 @@ public void Child_pwsh_location_change_remains_isolated() a => a.Raw == "child.txt" && a.Resolved == "C:/work/child.txt"); } + [Fact] + public void Child_pwsh_inherits_exact_invocation_location() + { + var result = Parse( + "Set-Location C:\\a; pwsh -Command 'Get-Item child.txt'"); + var child = result.Clauses.Last(); + + Assert.Contains(child.Args, + argument => argument.Raw == "child.txt" + && argument.Resolved == "C:/a/child.txt"); + } + + [Fact] + public void Child_pwsh_inherits_dynamic_invocation_location_conservatively() + { + var result = Parse( + "Set-Location $target; pwsh -Command 'Get-Item child.txt'"); + var child = result.Clauses.Last(); + var argument = Assert.Single(child.Args, candidate => candidate.Raw == "child.txt"); + + Assert.Equal(ArgKind.DynamicSkip, argument.Kind); + Assert.False(argument.IsPath); + Assert.Null(argument.Resolved); + Assert.Contains(child.Args, candidate => + candidate.IsCwdAttribution && candidate.Kind == ArgKind.DynamicSkip); + } + [Fact] public void Invoke_expression_depth_five_parses() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index 6046c84..c7900ea 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -41,6 +41,8 @@ public void Literal_array_preserves_binding_iterable_body_and_exact_spans() Assert.Same(body.Clause, Assert.Single(result.Clauses)); Assert.Equal(CommandOccurrenceRole.LoopBody, occurrence.ImmediateRole); Assert.False(occurrence.IsComplete); + Assert.All(occurrence.EffectiveArguments, argument => + Assert.Equal(ShellValueDomainKind.Unknown, argument.Value.Kind)); } [Fact] diff --git a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs index d502dc5..82ab8eb 100644 --- a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs @@ -219,12 +219,27 @@ public void PwshParserOptions_has_expected_shape() AssertInitProperty(t, "HomeDirectory", typeof(string), nullable: true); AssertInitProperty(t, "WorkingDirectory", typeof(string), nullable: true); + AssertInitProperty(t, "InitialStateMode", typeof(PwshInitialStateMode)); var declaredProps = DeclaredInstanceProps(t) .Where(p => p.Name != "EqualityContract") .Select(p => p.Name) .ToArray(); - Assert.Empty(declaredProps); + Assert.Equal(new[] { "InitialStateMode" }, declaredProps); + } + + [Fact] + public void PwshInitialStateMode_has_expected_values_and_safe_default() + { + Assert.Equal(0, (int)PwshInitialStateMode.Unknown); + Assert.Equal(1, (int)PwshInitialStateMode.IsolatedNonInteractiveNoProfile); + Assert.Equal(PwshInitialStateMode.Unknown, new PwshParserOptions().InitialStateMode); + Assert.Equal( + PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + new PwshParserOptions + { + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }.InitialStateMode); } // -------- ParsedCommand -------- @@ -527,6 +542,7 @@ public void Public_namespace_contains_only_expected_types() nameof(ParsedCommand), nameof(PipelineSyntax), nameof(PwshParser), + nameof(PwshInitialStateMode), nameof(PwshParserOptions), nameof(Redirect), nameof(RedirectAnalysis),