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),