diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md
index 7450fb0..fff8fc2 100644
--- a/IMPLEMENTATION_PLAN.md
+++ b/IMPLEMENTATION_PLAN.md
@@ -319,10 +319,18 @@ priorities.
`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
+ cases select the mode individually and pin default `Unknown`. The first
+ value-analysis pass now consumes that contract, retains parser-owned
+ argument provenance, proves quoted scalar and literal-array domains,
+ retains ordered duplicate visits separately from public set summaries,
+ guards a pinned documented preference inventory plus fresh-host built-ins
+ with a live PowerShell oracle, composes case-insensitive distinct nested
+ bindings, and leaves pipeline
+ objects, null, overflow, wrappers, redirects, same-name overwrites, and
+ post-loop state conservative. Task 7.4 must add ordered persistent scope
+ and location transfers without restoring parser-frame bindings. Expand the
design corpus for cardinality, mutation, separators, wrappers, redirects,
- and transition caps before tasks 7.3-7.7. The simple-command slice is
+ and transition caps before tasks 7.4-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
diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md
index 940ad5b..5e62909 100644
--- a/SPEC.POWERSHELL.md
+++ b/SPEC.POWERSHELL.md
@@ -431,11 +431,16 @@ 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.
+case-insensitively collide with PowerShell's automatic, constant, read-only,
+typed, validated, preference, or configuration variables are eligible.
+Scoped/provider bindings such as `$global:x`, `$script:x`, `$private:x`, and
+`$env:X` fail the loop region closed. A built-in or ambient binding therefore
+cannot coerce, reject, or otherwise alter a value the analyzer presents as an
+exact string, or change host behavior as a side effect of loop assignment.
+The supported preference-variable inventory is pinned to PowerShell's
+`about_Preference_Variables` reference rather than inferred only from variables
+materialized by a fresh host; lazy and configuration-dependent names remain
+ineligible even when `Get-Variable` does not initially enumerate them.
Parenthesized groups, `$()`, and static `Invoke-Expression` execute in the
current runspace and share supported binding, command-resolution, and location
diff --git a/SPEC.md b/SPEC.md
index 537e626..1a73493 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -297,12 +297,17 @@ 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 mode does not erase PowerShell's built-in variable state. Exact and finite
+binding proofs remain limited to ordinary unscoped variable names that do not
+collide, case-insensitively, with automatic, constant, read-only, typed,
+validated, preference, or configuration bindings known to the supported
+PowerShell runtime. A `foreach` assignment to a built-in preference variable
+can coerce an authored string into an enum or reject it, and can change host
+behavior independently of the loop value; it is therefore not an ordinary
+string binding. Documented preference names remain excluded even when they are
+lazy or configuration-dependent and therefore absent from a fresh
+`Get-Variable` inventory. 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
diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md
index 6d5daba..8d04965 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/design.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/design.md
@@ -440,8 +440,13 @@ 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
+that do not case-insensitively collide with automatic, constant, read-only,
+typed, validated, preference, or configuration variables known to the
+supported runtime. Built-in preference variables are excluded because
+assignment can coerce or reject authored strings or alter host behavior. The
+reviewed documented inventory is pinned independently from the live fresh-host
+oracle so lazy and configuration-dependent preferences cannot escape the gate.
+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
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 6518809..f59d4fb 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
@@ -185,7 +185,11 @@ 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.
+case-insensitively collide with automatic, constant, read-only, typed,
+validated, preference, or configuration variables known to the supported
+PowerShell runtime. The preference inventory SHALL include documented lazy and
+configuration-dependent names even when a fresh `Get-Variable` inventory omits
+them.
Scoped/provider binding forms SHALL fail closed.
Current-runspace groups, `$()`, and static `Invoke-Expression` payloads SHALL
@@ -211,6 +215,11 @@ initial-state assertion.
- **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: Built-in preference binding is not an ordinary string slot
+- **WHEN** isolated-mode PowerShell parses a loop binding named `ConfirmPreference`, `ErrorActionPreference`, or another known built-in preference or configuration variable
+- **THEN** the complete loop region is unparseable
+- **THEN** the analyzer does not assume assignment avoids type coercion, validation, rejection, or host-behavior changes
+
#### 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
diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
index 764765c..6557343 100644
--- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md
+++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md
@@ -133,7 +133,18 @@
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.
+- [x] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings.
+ - The PowerShell-specific value pass consumes lexer provenance, composes
+ case-insensitive distinct active bindings, publishes bounded literal
+ scalar/array domains only under the explicit isolated-runspace contract,
+ and collapses object, null, unsupported, and over-cap values to Unknown.
+ The internal plan retains ordered duplicate visits and an exact authored
+ count separately from its public set summary; unknown object iterables are
+ zero-or-more. Reserved or stateful built-in binding collisions fail
+ atomically; a pinned documented preference inventory covers lazy names and a
+ live PowerShell 7.x oracle guards the fresh-host inventory. Decoded
+ child hosts, current-runspace wrappers, redirect values, same-name nested
+ overwrites, and post-loop state remain conservative for tasks 7.4-7.6.
- [ ] 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.
- [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow.
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs
index 1ea749d..857b721 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs
@@ -53,6 +53,14 @@ private bool TryParseForEach(
}
var bindingToken = _tokens[_position++];
+ if (_options.InitialStateMode ==
+ PwshInitialStateMode.IsolatedNonInteractiveNoProfile &&
+ !PwshForEachValueAnalysis.IsEligibleBindingName(bindingName))
+ {
+ error = "PowerShell foreach binding collides with a reserved or stateful built-in variable";
+ return false;
+ }
+
if (_position == _tokens.Count ||
_tokens[_position].Kind != PwshTokenKind.Word ||
!string.Equals(
@@ -74,6 +82,7 @@ private bool TryParseForEach(
}
var iterableTokens = CopyTokens(iterableStart, closePosition);
+ var isLiteralIterable = IsLiteralForEachExpression(iterableTokens);
var firstIterable = iterableTokens[0];
var lastIterable = iterableTokens[iterableTokens.Count - 1];
var iterableSourceStart = firstIterable.SourceStart;
@@ -110,7 +119,7 @@ private bool TryParseForEach(
return false;
}
- command = new ForEachSyntax
+ var forEach = new ForEachSyntax
{
Binding = new LoopBindingSyntax
{
@@ -136,6 +145,13 @@ private bool TryParseForEach(
SourceLength = bodyToken.SourceStart + bodyToken.SourceLength -
start.SourceStart,
};
+ _forEachPlans.Add(
+ forEach,
+ PwshForEachValueAnalysis.CapturePlan(
+ bindingName,
+ iterableTokens,
+ isLiteralIterable));
+ command = forEach;
error = null;
return true;
}
@@ -267,7 +283,13 @@ private bool TryParseForEachIterator(
sourceLength,
CompoundOperator.None,
insideCommandSubstitution: false);
- return coordinator.TryParse(out iterator, out error);
+ if (!coordinator.TryParse(out iterator, out error))
+ {
+ return false;
+ }
+
+ MergeFacts(coordinator);
+ return true;
}
private bool TryParseForEachBody(
@@ -318,7 +340,13 @@ private bool TryParseForEachBody(
sourceLength,
CompoundOperator.None,
insideCommandSubstitution: false);
- return coordinator.TryParse(out body, out error);
+ if (!coordinator.TryParse(out body, out error))
+ {
+ return false;
+ }
+
+ MergeFacts(coordinator);
+ return true;
}
private static bool TryReadSimpleLoopBinding(PwshToken token, out string name)
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs
new file mode 100644
index 0000000..2ed91f6
--- /dev/null
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs
@@ -0,0 +1,839 @@
+// -----------------------------------------------------------------------
+//
+// Copyright (C) 2026 - 2026 Aaron Stannard
+//
+// -----------------------------------------------------------------------
+using System;
+using System.Collections.Generic;
+using System.Runtime.CompilerServices;
+using System.Text;
+using ShellSyntaxTree.Internal.Pwsh.Lexing;
+using ShellSyntaxTree.Internal.Resolving;
+
+namespace ShellSyntaxTree.Internal.Pwsh.Parsing;
+
+internal enum PwshIterationCardinality
+{
+ Never,
+ OneOrMore,
+ ZeroOrMore,
+}
+
+internal sealed record PwshForEachAnalysisPlan(
+ string BindingName,
+ IReadOnlyList OrderedCandidates,
+ int? AuthoredVisitCount,
+ PwshIterationCardinality Cardinality,
+ bool RequiresFixedPoint,
+ ShellValueDomain Summary);
+
+internal static class PwshForEachValueAnalysis
+{
+ // PowerShell 7.x fresh-host and contextual built-ins whose assignment can
+ // fail, coerce values, or mutate host behavior. The live oracle test keeps
+ // the fresh-host portion synchronized as supported 7.x releases evolve.
+ private static readonly HashSet IneligibleBindingNames = new(
+ new[]
+ {
+ "_", "args", "ConfirmPreference", "ConsoleFileName", "DebugPreference",
+ "EnabledExperimentalFeatures", "Error", "ErrorActionPreference", "ErrorView",
+ "Event", "EventArgs", "EventSubscriber", "ExecutionContext", "false",
+ "foreach", "FormatEnumerationLimit", "HOME", "Host",
+ "InformationPreference", "input", "IsCoreCLR", "IsLinux", "IsMacOS",
+ "IsWindows", "LASTEXITCODE", "LogCommandHealthEvent",
+ "LogCommandLifecycleEvent", "LogEngineHealthEvent",
+ "LogEngineLifecycleEvent", "LogProviderHealthEvent",
+ "LogProviderLifecycleEvent", "Matches", "MaximumAliasCount",
+ "MaximumDriveCount", "MaximumErrorCount", "MaximumFunctionCount",
+ "MaximumHistoryCount", "MaximumVariableCount", "MyInvocation",
+ "NestedPromptLevel", "null", "OFS", "OutputEncoding", "PID", "PROFILE",
+ "ProgressPreference", "PSBoundParameters", "PSCmdlet", "PSCommandPath",
+ "PSCulture", "PSDebugContext", "PSDefaultParameterValues", "PSEdition",
+ "PSEmailServer", "PSHOME", "PSItem", "PSModuleAutoLoadingPreference",
+ "PSNativeCommandArgumentPassing", "PSNativeCommandUseErrorActionPreference",
+ "PSScriptRoot", "PSSenderInfo", "PSSessionApplicationName",
+ "PSSessionConfigurationName", "PSSessionOption", "PSStyle", "PSUICulture",
+ "PSVersionTable", "PWD", "Sender", "ShellId", "StackTrace", "switch",
+ "this", "Transcript", "true", "VerbosePreference", "WarningPreference",
+ "WhatIfPreference",
+ },
+ StringComparer.OrdinalIgnoreCase);
+
+ internal static bool IsEligibleBindingName(string name) =>
+ !IneligibleBindingNames.Contains(name);
+
+ internal static PwshForEachAnalysisPlan CapturePlan(
+ string bindingName,
+ IReadOnlyList iterableTokens,
+ bool isLiteralExpression)
+ {
+ if (iterableTokens.Count != 1)
+ {
+ return UnknownPlan(bindingName, PwshIterationCardinality.ZeroOrMore);
+ }
+
+ var token = iterableTokens[0];
+ if (token.Kind == PwshTokenKind.QuotedString &&
+ !token.HasInterpolation &&
+ TryGetLiteralValue(token.ResolverValue, out var scalar))
+ {
+ return new PwshForEachAnalysisPlan(
+ bindingName,
+ new[] { Exact(scalar) },
+ AuthoredVisitCount: 1,
+ PwshIterationCardinality.OneOrMore,
+ RequiresFixedPoint: false,
+ Exact(scalar));
+ }
+
+ if (token.Kind == PwshTokenKind.Subexpression &&
+ token.Value.StartsWith("@(", StringComparison.Ordinal) &&
+ TryCaptureLiteralArray(
+ token.Value,
+ out var ordered,
+ out var authoredVisitCount,
+ out var arrayCardinality,
+ out var requiresFixedPoint,
+ out var summary))
+ {
+ return new PwshForEachAnalysisPlan(
+ bindingName,
+ ordered,
+ authoredVisitCount,
+ arrayCardinality,
+ requiresFixedPoint,
+ summary);
+ }
+
+ if (token.Kind == PwshTokenKind.Word &&
+ string.Equals(token.Value, "$null", StringComparison.OrdinalIgnoreCase))
+ {
+ return new PwshForEachAnalysisPlan(
+ bindingName,
+ Array.Empty(),
+ AuthoredVisitCount: 0,
+ PwshIterationCardinality.Never,
+ RequiresFixedPoint: false,
+ ShellValueDomain.Unknown);
+ }
+
+ return isLiteralExpression
+ ? new PwshForEachAnalysisPlan(
+ bindingName,
+ new[] { ShellValueDomain.Unknown },
+ AuthoredVisitCount: 1,
+ PwshIterationCardinality.OneOrMore,
+ RequiresFixedPoint: false,
+ ShellValueDomain.Unknown)
+ : UnknownPlan(bindingName, PwshIterationCardinality.ZeroOrMore);
+ }
+
+ private static PwshForEachAnalysisPlan UnknownPlan(
+ string bindingName,
+ PwshIterationCardinality cardinality) =>
+ new(
+ bindingName,
+ Array.Empty(),
+ AuthoredVisitCount: null,
+ cardinality,
+ RequiresFixedPoint: true,
+ ShellValueDomain.Unknown);
+
+ private static bool TryCaptureLiteralArray(
+ string raw,
+ out IReadOnlyList orderedCandidates,
+ out int authoredVisitCount,
+ out PwshIterationCardinality cardinality,
+ out bool requiresFixedPoint,
+ out ShellValueDomain summary)
+ {
+ orderedCandidates = Array.Empty();
+ authoredVisitCount = 0;
+ cardinality = PwshIterationCardinality.Never;
+ requiresFixedPoint = false;
+ summary = ShellValueDomain.Unknown;
+ var index = 2;
+ var end = raw.Length - 1;
+ SkipWhitespace(raw, ref index, end);
+ if (index == end)
+ {
+ return true;
+ }
+
+ var values = new List();
+ var ordered = new List();
+ var distinct = new HashSet(StringComparer.Ordinal);
+ var allStrings = true;
+ var summaryExceeded = false;
+ var count = 0;
+ while (index < end)
+ {
+ var start = index;
+ if (!TryReadElement(raw, ref index, end))
+ {
+ return false;
+ }
+
+ count++;
+ var element = raw.Substring(start, index - start);
+ if (!TryDecodeQuotedElement(element, out var value))
+ {
+ allStrings = false;
+ if (count <= ShellAnalysisLimits.MaxValueCandidates)
+ {
+ ordered.Add(ShellValueDomain.Unknown);
+ }
+ }
+ else
+ {
+ if (count <= ShellAnalysisLimits.MaxValueCandidates)
+ {
+ ordered.Add(Exact(value));
+ }
+
+ if (!summaryExceeded && distinct.Add(value))
+ {
+ if (values.Count == ShellAnalysisLimits.MaxValueCandidates)
+ {
+ summaryExceeded = true;
+ }
+ else
+ {
+ values.Add(value);
+ }
+ }
+ }
+
+ SkipWhitespace(raw, ref index, end);
+ if (index == end)
+ {
+ break;
+ }
+
+ if (raw[index++] != ',')
+ {
+ return false;
+ }
+
+ SkipWhitespace(raw, ref index, end);
+ }
+
+ cardinality = count == 0
+ ? PwshIterationCardinality.Never
+ : PwshIterationCardinality.OneOrMore;
+ authoredVisitCount = count;
+ requiresFixedPoint = count > ShellAnalysisLimits.MaxValueCandidates;
+ orderedCandidates = requiresFixedPoint
+ ? Array.Empty()
+ : ordered.ToArray();
+ if (allStrings && !summaryExceeded)
+ {
+ summary = CreateFiniteDomain(values);
+ }
+
+ return true;
+ }
+
+ private static bool TryReadElement(string raw, ref int index, int end)
+ {
+ if (index >= end)
+ {
+ return false;
+ }
+
+ if (raw[index] is not ('\'' or '"'))
+ {
+ while (index < end && raw[index] != ',' && !char.IsWhiteSpace(raw[index]))
+ {
+ index++;
+ }
+
+ return true;
+ }
+
+ var quote = raw[index++];
+ while (index < end)
+ {
+ if (quote == '"' && raw[index] == '`' && index + 1 < end)
+ {
+ index += 2;
+ continue;
+ }
+
+ if (raw[index] != quote)
+ {
+ index++;
+ continue;
+ }
+
+ if (quote == '\'' && index + 1 < end && raw[index + 1] == '\'')
+ {
+ index += 2;
+ continue;
+ }
+
+ index++;
+ return true;
+ }
+
+ return false;
+ }
+
+ private static bool TryDecodeQuotedElement(string raw, out string value)
+ {
+ value = string.Empty;
+ if (raw.Length < 2 || raw[0] is not ('\'' or '"'))
+ {
+ return false;
+ }
+
+ var tokens = PwshLexer.Tokenize(raw);
+ if (tokens.Count != 1 ||
+ tokens[0].Kind != PwshTokenKind.QuotedString ||
+ tokens[0].HasInterpolation ||
+ !TryGetLiteralValue(tokens[0].ResolverValue, out value))
+ {
+ value = string.Empty;
+ return false;
+ }
+
+ return true;
+ }
+
+ private static bool TryGetLiteralValue(ShellValue? value, out string literal)
+ {
+ literal = string.Empty;
+ if (value is null)
+ {
+ return false;
+ }
+
+ foreach (var fragment in value.Fragments)
+ {
+ if (fragment.Kind != ShellValueFragmentKind.Literal)
+ {
+ return false;
+ }
+ }
+
+ literal = value.Decoded;
+ return true;
+ }
+
+ private static ShellValueDomain Exact(string value) => new()
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { value },
+ };
+
+ private static ShellValueDomain CreateFiniteDomain(IReadOnlyList values) =>
+ values.Count switch
+ {
+ 0 => ShellValueDomain.Unknown,
+ 1 => Exact(values[0]),
+ _ => new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = Copy(values),
+ },
+ };
+
+ private static string[] Copy(IReadOnlyList values)
+ {
+ var copy = new string[values.Count];
+ for (var index = 0; index < copy.Length; index++)
+ {
+ copy[index] = values[index];
+ }
+
+ return copy;
+ }
+
+ private static void SkipWhitespace(string raw, ref int index, int end)
+ {
+ while (index < end && char.IsWhiteSpace(raw[index]))
+ {
+ index++;
+ }
+ }
+}
+
+internal sealed class PwshForEachValueAnalyzer
+{
+ private readonly Func _factsFactory;
+ private readonly Func _planFactory;
+ private readonly IReadOnlyList _incompleteClauses;
+ private readonly Dictionary _facts =
+ new(ClauseReferenceComparer.Instance);
+ private readonly bool _isolatedInitialState;
+
+ private PwshForEachValueAnalyzer(
+ PwshParserOptions options,
+ Func factsFactory,
+ Func planFactory,
+ IReadOnlyList incompleteClauses)
+ {
+ _factsFactory = factsFactory;
+ _planFactory = planFactory;
+ _incompleteClauses = incompleteClauses;
+ _isolatedInitialState = options.InitialStateMode ==
+ PwshInitialStateMode.IsolatedNonInteractiveNoProfile;
+ }
+
+ internal static bool TryAnalyze(
+ ShellBlockSyntax syntax,
+ PwshParserOptions options,
+ Func factsFactory,
+ Func planFactory,
+ IReadOnlyList incompleteClauses,
+ out Func analyzedFacts)
+ {
+ var analyzer = new PwshForEachValueAnalyzer(
+ options,
+ factsFactory,
+ planFactory,
+ incompleteClauses);
+ analyzer.AnalyzeBlock(
+ syntax,
+ new AnalysisContext(
+ canPromote: analyzer._isolatedInitialState,
+ insideLoop: false,
+ new List()));
+ analyzedFacts = simple => analyzer._facts.TryGetValue(simple.Clause, out var facts)
+ ? facts
+ : factsFactory(simple);
+ return true;
+ }
+
+ private AnalysisContext AnalyzeNode(ShellSyntaxNode node, AnalysisContext input) =>
+ node switch
+ {
+ ShellBlockSyntax block => AnalyzeBlock(block, input),
+ SimpleCommandSyntax simple => AnalyzeSimple(simple, input),
+ CommandListSyntax list => AnalyzeList(list, input),
+ PipelineSyntax pipeline => AnalyzePipeline(pipeline, input),
+ GroupSyntax group => AnalyzeGroup(group, input),
+ ForEachSyntax forEach => AnalyzeForEach(forEach, input),
+ CommandSubstitutionSyntax substitution => AnalyzeSubstitution(substitution, input),
+ _ => input.Invalidate(),
+ };
+
+ private AnalysisContext AnalyzeBlock(ShellBlockSyntax block, AnalysisContext input)
+ {
+ var current = input;
+ foreach (var statement in block.Statements)
+ {
+ current = AnalyzeNode(statement, current);
+ }
+
+ return current;
+ }
+
+ private AnalysisContext AnalyzeSimple(SimpleCommandSyntax simple, AnalysisContext input)
+ {
+ foreach (var substitution in simple.Substitutions)
+ {
+ AnalyzeBlock(substitution.Body, input.Invalidate());
+ }
+
+ var source = _factsFactory(simple);
+ var effective = CreateEffectiveArguments(source.ValueProvenance, input);
+ var mayPromote = input.InsideLoop &&
+ input.CanPromote &&
+ source.HasCompleteValueProvenance &&
+ simple.Substitutions.Count == 0 &&
+ !simple.Clause.IsCommandStringWrapped;
+ _facts.Add(simple.Clause, new CommandOccurrenceFacts
+ {
+ EffectiveArguments = effective,
+ WorkingDirectory = source.WorkingDirectory,
+ Redirects = source.Redirects,
+ CwdPathDependencies = source.CwdPathDependencies,
+ ValueProvenance = source.ValueProvenance,
+ HasCompleteValueProvenance = source.HasCompleteValueProvenance,
+ IsComplete = source.IsComplete &&
+ (!ContainsReference(_incompleteClauses, simple.Clause) || mayPromote),
+ });
+
+ return simple.Substitutions.Count == 0 ? input : input.Invalidate();
+ }
+
+ private AnalysisContext AnalyzeList(CommandListSyntax list, AnalysisContext input)
+ {
+ var current = input;
+ foreach (var item in list.Items)
+ {
+ current = AnalyzeNode(item.Command, current);
+ }
+
+ return current;
+ }
+
+ private AnalysisContext AnalyzePipeline(PipelineSyntax pipeline, AnalysisContext input)
+ {
+ var result = input;
+ foreach (var stage in pipeline.Stages)
+ {
+ if (!AnalyzeNode(stage, input).CanPromote)
+ {
+ result = result.Invalidate();
+ }
+ }
+
+ return result;
+ }
+
+ private AnalysisContext AnalyzeGroup(GroupSyntax group, AnalysisContext input)
+ {
+ AnalyzeBlock(group.Body, input.Invalidate());
+ return group.GroupKind == ShellGroupKind.IsolatedScope
+ ? input
+ : input.Invalidate();
+ }
+
+ private AnalysisContext AnalyzeSubstitution(
+ CommandSubstitutionSyntax substitution,
+ AnalysisContext input)
+ {
+ AnalyzeBlock(substitution.Body, input.Invalidate());
+ return input.Invalidate();
+ }
+
+ private AnalysisContext AnalyzeForEach(ForEachSyntax forEach, AnalysisContext input)
+ {
+ var plan = _planFactory(forEach);
+ if (plan is null)
+ {
+ AnalyzeBlock(forEach.IteratorCommands, input.Invalidate());
+ AnalyzeBlock(forEach.Body, input.Invalidate());
+ return input.Invalidate();
+ }
+
+ var iteratorOutput = AnalyzeBlock(forEach.IteratorCommands, input);
+ var sameName = iteratorOutput.ContainsBinding(plan.BindingName);
+ var canPromote = _isolatedInitialState &&
+ iteratorOutput.CanPromote &&
+ !sameName &&
+ PwshForEachValueAnalysis.IsEligibleBindingName(plan.BindingName);
+ var domain = canPromote ? plan.Summary : ShellValueDomain.Unknown;
+ var bodyInput = iteratorOutput.WithBinding(
+ plan.BindingName,
+ domain,
+ canPromote);
+ AnalyzeBlock(forEach.Body, bodyInput);
+
+ // PowerShell foreach assignments persist in the current scope. Task
+ // 7.4 owns the ordered post-loop state; until then no later command
+ // receives a restored parser-frame value.
+ return input.Invalidate();
+ }
+
+ private static IReadOnlyList CreateEffectiveArguments(
+ IReadOnlyList provenance,
+ AnalysisContext context)
+ {
+ var effective = new List();
+ foreach (var value in provenance)
+ {
+ if (context.TryAnalyzeEffectiveValue(value.Value, out var domain))
+ {
+ effective.Add(new EffectiveArgument
+ {
+ ClauseElementIndex = value.ClauseElementIndex,
+ Value = domain,
+ });
+ }
+ }
+
+ return effective.ToArray();
+ }
+
+ private static bool ContainsReference(IReadOnlyList clauses, Clause expected)
+ {
+ foreach (var clause in clauses)
+ {
+ if (ReferenceEquals(clause, expected))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ private sealed class BindingFrame
+ {
+ internal BindingFrame(string name, ShellValueDomain domain)
+ {
+ Name = name;
+ Domain = domain;
+ }
+
+ internal string Name { get; }
+
+ internal ShellValueDomain Domain { get; }
+ }
+
+ private readonly struct AnalysisContext
+ {
+ private readonly IReadOnlyList _bindings;
+
+ internal AnalysisContext(
+ bool canPromote,
+ bool insideLoop,
+ IReadOnlyList bindings)
+ {
+ CanPromote = canPromote;
+ InsideLoop = insideLoop;
+ _bindings = bindings;
+ }
+
+ internal bool CanPromote { get; }
+
+ internal bool InsideLoop { get; }
+
+ internal AnalysisContext Invalidate()
+ {
+ if (_bindings.Count == 0)
+ {
+ return new AnalysisContext(false, InsideLoop, _bindings);
+ }
+
+ var unknown = new BindingFrame[_bindings.Count];
+ for (var index = 0; index < unknown.Length; index++)
+ {
+ unknown[index] = new BindingFrame(
+ _bindings[index].Name,
+ ShellValueDomain.Unknown);
+ }
+
+ return new AnalysisContext(false, InsideLoop, unknown);
+ }
+
+ internal bool ContainsBinding(string name) => FindBinding(name) is not null;
+
+ internal AnalysisContext WithBinding(
+ string name,
+ ShellValueDomain domain,
+ bool canPromote)
+ {
+ var bindings = new List(_bindings.Count + 1);
+ foreach (var binding in _bindings)
+ {
+ if (!string.Equals(binding.Name, name, StringComparison.OrdinalIgnoreCase))
+ {
+ bindings.Add(binding);
+ }
+ }
+
+ bindings.Add(new BindingFrame(name, domain));
+ return new AnalysisContext(canPromote, insideLoop: true, bindings);
+ }
+
+ internal bool TryAnalyzeEffectiveValue(
+ ShellValue value,
+ out ShellValueDomain domain)
+ {
+ var referenced = new List();
+ var unresolved = false;
+ foreach (var fragment in value.Fragments)
+ {
+ if (fragment.Kind == ShellValueFragmentKind.Literal)
+ {
+ continue;
+ }
+
+ if (fragment.Kind != ShellValueFragmentKind.Expansion ||
+ fragment.Expansion is not ShellExpansionReference expansion ||
+ expansion.Kind != ShellExpansionKind.Variable ||
+ expansion.Name is null)
+ {
+ unresolved = true;
+ continue;
+ }
+
+ var binding = FindBinding(expansion.Name);
+ if (binding is null)
+ {
+ unresolved = true;
+ }
+ else if (!ContainsReference(referenced, binding))
+ {
+ referenced.Add(binding);
+ }
+ }
+
+ if (referenced.Count == 0)
+ {
+ domain = ShellValueDomain.Unknown;
+ return false;
+ }
+
+ if (unresolved)
+ {
+ domain = ShellValueDomain.Unknown;
+ return true;
+ }
+
+ foreach (var binding in referenced)
+ {
+ if (binding.Domain.Kind is not (
+ ShellValueDomainKind.Exact or ShellValueDomainKind.FiniteSet))
+ {
+ domain = ShellValueDomain.Unknown;
+ return true;
+ }
+ }
+
+ var selected = new Dictionary();
+ var candidates = new List();
+ var distinct = new HashSet(StringComparer.Ordinal);
+ if (!TryCompose(
+ value,
+ referenced,
+ bindingIndex: 0,
+ selected,
+ candidates,
+ distinct))
+ {
+ domain = ShellValueDomain.Unknown;
+ return true;
+ }
+
+ domain = candidates.Count == 1
+ ? new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.Exact,
+ Values = new[] { candidates[0] },
+ }
+ : new ShellValueDomain
+ {
+ Kind = ShellValueDomainKind.FiniteSet,
+ Values = candidates.ToArray(),
+ };
+ return true;
+ }
+
+ private bool TryCompose(
+ ShellValue value,
+ IReadOnlyList bindings,
+ int bindingIndex,
+ Dictionary selected,
+ List candidates,
+ HashSet distinct)
+ {
+ if (bindingIndex == bindings.Count)
+ {
+ var rendered = Render(value, selected);
+ if (rendered is null)
+ {
+ return false;
+ }
+
+ if (distinct.Add(rendered))
+ {
+ if (distinct.Count > ShellAnalysisLimits.MaxValueCandidates)
+ {
+ return false;
+ }
+
+ candidates.Add(rendered);
+ }
+
+ return true;
+ }
+
+ var binding = bindings[bindingIndex];
+ foreach (var candidate in binding.Domain.Values)
+ {
+ selected[binding] = candidate;
+ if (!TryCompose(
+ value,
+ bindings,
+ bindingIndex + 1,
+ selected,
+ candidates,
+ distinct))
+ {
+ return false;
+ }
+ }
+
+ selected.Remove(binding);
+ return true;
+ }
+
+ private string? Render(
+ ShellValue value,
+ IReadOnlyDictionary selected)
+ {
+ var rendered = new StringBuilder(value.Decoded.Length);
+ foreach (var fragment in value.Fragments)
+ {
+ if (fragment.Kind == ShellValueFragmentKind.Literal)
+ {
+ rendered.Append(fragment.Value);
+ continue;
+ }
+
+ if (fragment.Kind != ShellValueFragmentKind.Expansion ||
+ fragment.Expansion is not ShellExpansionReference expansion ||
+ expansion.Kind != ShellExpansionKind.Variable ||
+ expansion.Name is null)
+ {
+ return null;
+ }
+
+ var binding = FindBinding(expansion.Name);
+ if (binding is null || !selected.TryGetValue(binding, out var candidate))
+ {
+ return null;
+ }
+
+ rendered.Append(candidate);
+ }
+
+ return rendered.ToString();
+ }
+
+ private BindingFrame? FindBinding(string name)
+ {
+ for (var index = _bindings.Count - 1; index >= 0; index--)
+ {
+ if (string.Equals(
+ _bindings[index].Name,
+ name,
+ StringComparison.OrdinalIgnoreCase))
+ {
+ return _bindings[index];
+ }
+ }
+
+ return null;
+ }
+
+ private static bool ContainsReference(
+ IReadOnlyList bindings,
+ BindingFrame expected)
+ {
+ foreach (var binding in bindings)
+ {
+ if (ReferenceEquals(binding, expected))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ }
+
+ private sealed class ClauseReferenceComparer : IEqualityComparer
+ {
+ internal static ClauseReferenceComparer Instance { get; } = new();
+
+ public bool Equals(Clause? x, Clause? y) => ReferenceEquals(x, y);
+
+ public int GetHashCode(Clause obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+}
diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs
index b7c26ce..79599ce 100644
--- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs
+++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshStructuralCoordinator.cs
@@ -5,6 +5,7 @@
// -----------------------------------------------------------------------
using System;
using System.Collections.Generic;
+using System.Runtime.CompilerServices;
using ShellSyntaxTree.Internal.Parsing;
using ShellSyntaxTree.Internal.Pwsh.Lexing;
using ShellSyntaxTree.Internal.Resolving;
@@ -40,13 +41,16 @@ private static ParsedCommand ParseStructured(
}
var incompleteForEachClauses = CollectIncompleteForEachClauses(syntax);
- if (!ShellSyntaxProjection.TryProject(
+ if (!PwshForEachValueAnalyzer.TryAnalyze(
syntax,
- simple => new CommandOccurrenceFacts
- {
- IsComplete = IsStructurallyComplete(simple) &&
- !ContainsReference(incompleteForEachClauses, simple.Clause),
- },
+ options,
+ coordinator.GetFacts,
+ coordinator.GetForEachPlan,
+ incompleteForEachClauses,
+ out var analyzedFacts) ||
+ !ShellSyntaxProjection.TryProject(
+ syntax,
+ analyzedFacts,
out var projection))
{
return StructuralFailure(
@@ -90,6 +94,10 @@ private sealed partial class StructuralCoordinator
private readonly int _sourceLength;
private readonly CompoundOperator _firstCompatibilityOperator;
private readonly bool _insideCommandSubstitution;
+ private readonly Dictionary _facts =
+ new(ClauseReferenceComparer.Instance);
+ private readonly Dictionary _forEachPlans =
+ new(ForEachReferenceComparer.Instance);
private int _position;
private int _groupDepth;
@@ -119,6 +127,17 @@ internal StructuralCoordinator(
_insideCommandSubstitution = insideCommandSubstitution;
}
+ internal CommandOccurrenceFacts GetFacts(SimpleCommandSyntax simple) =>
+ _facts.TryGetValue(simple.Clause, out var facts)
+ ? facts
+ : new CommandOccurrenceFacts
+ {
+ IsComplete = IsStructurallyComplete(simple),
+ };
+
+ internal PwshForEachAnalysisPlan? GetForEachPlan(ForEachSyntax forEach) =>
+ _forEachPlans.TryGetValue(forEach, out var plan) ? plan : null;
+
internal bool TryParse(out ShellBlockSyntax syntax, out string? error)
{
SkipNewlines();
@@ -520,13 +539,15 @@ private bool TryParseCommand(
var first = segmentTokens[0];
var last = segmentTokens[segmentTokens.Count - 1];
- command = new SimpleCommandSyntax
+ var simple = new SimpleCommandSyntax
{
Clause = clause,
Substitutions = substitutions,
SourceStart = first.SourceStart,
SourceLength = last.SourceStart + last.SourceLength - first.SourceStart,
};
+ RegisterFacts(simple, segmentTokens);
+ command = simple;
return true;
}
@@ -1274,7 +1295,13 @@ private bool TryParseSubstitutionBody(
sourceLength,
firstCompatibilityOperator,
insideCommandSubstitution: true);
- return coordinator.TryParse(out body, out error);
+ if (!coordinator.TryParse(out body, out error))
+ {
+ return false;
+ }
+
+ MergeFacts(coordinator);
+ return true;
}
}
@@ -1417,6 +1444,113 @@ private static bool ContainsReference(IReadOnlyList clauses, Clause targ
return false;
}
+ private sealed partial class StructuralCoordinator
+ {
+ private void RegisterFacts(
+ SimpleCommandSyntax simple,
+ IReadOnlyList sourceTokens)
+ {
+ var provenance = new List();
+ var hasCompleteProvenance = true;
+ for (var elementIndex = 0;
+ elementIndex < simple.Clause.Elements.Count;
+ elementIndex++)
+ {
+ var element = simple.Clause.Elements[elementIndex];
+ if (element.Role != ClauseElementRole.Argument)
+ {
+ continue;
+ }
+
+ if (!TryGetElementValue(element, sourceTokens, out var value))
+ {
+ hasCompleteProvenance = false;
+ continue;
+ }
+
+ provenance.Add(new ShellValueElementProvenance(elementIndex, value));
+ }
+
+ _facts.Add(simple.Clause, new CommandOccurrenceFacts
+ {
+ ValueProvenance = provenance.ToArray(),
+ HasCompleteValueProvenance = hasCompleteProvenance,
+ IsComplete = IsStructurallyComplete(simple),
+ });
+ }
+
+ private static bool TryGetElementValue(
+ ClauseElement element,
+ IReadOnlyList sourceTokens,
+ out ShellValue value)
+ {
+ value = ShellValue.Literal(string.Empty);
+ if (element.SourceStart is null || element.SourceLength is null)
+ {
+ return false;
+ }
+
+ var elementStart = element.SourceStart.Value;
+ var elementEnd = elementStart + element.SourceLength.Value;
+ var values = new List();
+ var coveredStart = -1;
+ var coveredEnd = -1;
+ foreach (var token in sourceTokens)
+ {
+ var tokenEnd = token.SourceStart + token.SourceLength;
+ if (token.SourceStart < elementStart || tokenEnd > elementEnd)
+ {
+ continue;
+ }
+
+ coveredStart = coveredStart < 0 ? token.SourceStart : coveredStart;
+ coveredEnd = tokenEnd;
+ values.Add(token.ResolverValue ??
+ ShellValue.Literal(token.Value, token.SourceStart, token.SourceLength));
+ }
+
+ if (values.Count == 0 || coveredStart != elementStart || coveredEnd != elementEnd)
+ {
+ return false;
+ }
+
+ value = values.Count == 1 ? values[0] : ShellValue.Concat(values);
+ return true;
+ }
+
+ private void MergeFacts(StructuralCoordinator nested)
+ {
+ foreach (var pair in nested._facts)
+ {
+ _facts.Add(pair.Key, pair.Value);
+ }
+
+ foreach (var pair in nested._forEachPlans)
+ {
+ _forEachPlans.Add(pair.Key, pair.Value);
+ }
+ }
+
+ private sealed class ClauseReferenceComparer : IEqualityComparer
+ {
+ internal static ClauseReferenceComparer Instance { get; } = new();
+
+ public bool Equals(Clause? x, Clause? y) => ReferenceEquals(x, y);
+
+ public int GetHashCode(Clause obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+
+ private sealed class ForEachReferenceComparer : IEqualityComparer
+ {
+ internal static ForEachReferenceComparer Instance { get; } = new();
+
+ public bool Equals(ForEachSyntax? x, ForEachSyntax? y) =>
+ ReferenceEquals(x, y);
+
+ public int GetHashCode(ForEachSyntax obj) => RuntimeHelpers.GetHashCode(obj);
+ }
+ }
+
private static IReadOnlyList ShiftTokens(
IReadOnlyList tokens,
int sourceOffset)
diff --git a/src/ShellSyntaxTree/ShellSyntaxProjection.cs b/src/ShellSyntaxTree/ShellSyntaxProjection.cs
index 6982289..bc7fcce 100644
--- a/src/ShellSyntaxTree/ShellSyntaxProjection.cs
+++ b/src/ShellSyntaxTree/ShellSyntaxProjection.cs
@@ -30,6 +30,8 @@ internal sealed class CommandOccurrenceFacts
internal IReadOnlyList ValueProvenance { get; init; } =
Array.Empty();
+ internal bool HasCompleteValueProvenance { get; init; }
+
internal bool IsComplete { get; init; }
}
diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
index 9ecea60..c1ca364 100644
--- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
+++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs
@@ -33,8 +33,12 @@ public void Corpus_entry_parses_to_expected_ast(string shell, string fileName, C
Assert.NotNull(entry);
Assert.False(string.IsNullOrEmpty(entry.Name), $"Corpus entry {fileName} has no name.");
Assert.NotNull(entry.Expected);
+ if (shell != "powershell")
+ {
+ Assert.Null(entry.PowerShellInitialStateMode);
+ }
- var actual = CreateParser(shell).Parse(entry.Input);
+ var actual = CreateParser(shell, entry.PowerShellInitialStateMode).Parse(entry.Input);
AstAssert.Equal(entry.Expected!, actual, $"{shell}/{fileName}");
AssertClauseElementInvariants(actual, $"{shell}/{fileName}");
AssertAuthoredTokenCoverage(shell, actual, $"{shell}/{fileName}");
@@ -536,22 +540,25 @@ private static void AssertClauseElementInvariants(ParsedCommand parsed, string c
/// have stable expected values across hosts (Linux CI, Windows CI, dev
/// machines).
///
- internal static IShellParser CreateParser(string shell) => shell switch
- {
- "bash" => new BashParser(new BashParserOptions
+ internal static IShellParser CreateParser(
+ string shell,
+ PwshInitialStateMode? powerShellInitialStateMode = null) => shell switch
{
- HomeDirectory = "/home/test",
- WorkingDirectory = "/work",
- InitialStateMode = BashInitialStateMode.IsolatedNonInteractive,
- }),
- "powershell" => new PwshParser(new PwshParserOptions
- {
- HomeDirectory = "C:/Users/user",
- WorkingDirectory = "C:/work",
- }),
- _ => throw new InvalidOperationException(
- $"No parser is registered for corpus shell directory '{shell}'."),
- };
+ "bash" => new BashParser(new BashParserOptions
+ {
+ HomeDirectory = "/home/test",
+ WorkingDirectory = "/work",
+ InitialStateMode = BashInitialStateMode.IsolatedNonInteractive,
+ }),
+ "powershell" => new PwshParser(new PwshParserOptions
+ {
+ HomeDirectory = "C:/Users/user",
+ WorkingDirectory = "C:/work",
+ InitialStateMode = powerShellInitialStateMode ?? PwshInitialStateMode.Unknown,
+ }),
+ _ => throw new InvalidOperationException(
+ $"No parser is registered for corpus shell directory '{shell}'."),
+ };
public static IEnumerable