Skip to content

Commit 1105e8a

Browse files
feat(pwsh): derive bounded foreach values (#100)
1 parent 6e85776 commit 1105e8a

29 files changed

Lines changed: 1784 additions & 58 deletions

IMPLEMENTATION_PLAN.md

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -319,10 +319,18 @@ priorities.
319319
`PwshInitialStateMode` API and safe-default contract are now locked;
320320
`-NoProfile -NonInteractive` alone is explicitly insufficient without a
321321
controlled startup, inherited environment, and module baseline. Design
322-
cases select the mode individually and pin default `Unknown`. Tasks
323-
7.3-7.4 must consume the contract rather than inferring isolation. Expand the
322+
cases select the mode individually and pin default `Unknown`. The first
323+
value-analysis pass now consumes that contract, retains parser-owned
324+
argument provenance, proves quoted scalar and literal-array domains,
325+
retains ordered duplicate visits separately from public set summaries,
326+
guards a pinned documented preference inventory plus fresh-host built-ins
327+
with a live PowerShell oracle, composes case-insensitive distinct nested
328+
bindings, and leaves pipeline
329+
objects, null, overflow, wrappers, redirects, same-name overwrites, and
330+
post-loop state conservative. Task 7.4 must add ordered persistent scope
331+
and location transfers without restoring parser-frame bindings. Expand the
324332
design corpus for cardinality, mutation, separators, wrappers, redirects,
325-
and transition caps before tasks 7.3-7.7. The simple-command slice is
333+
and transition caps before tasks 7.4-7.7. The simple-command slice is
326334
delivered for ordinary, adjacent, quoted, here-string, redirect, standalone,
327335
call-operator, dynamic-identity, and host-wrapper positions, with
328336
current-scope state propagation and bounded expression rejection pinned

SPEC.POWERSHELL.md

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -431,11 +431,16 @@ profiles, startup scripts, or uncontrolled ambient variables, aliases,
431431
functions, and modules.
432432

433433
Even under that assertion, only ordinary unscoped binding names that do not
434-
case-insensitively collide with PowerShell's automatic, constant, or read-only
435-
variables are eligible. Scoped/provider bindings such as `$global:x`,
436-
`$script:x`, `$private:x`, and `$env:X` fail the loop region closed. A typed,
437-
validated, constant, or read-only ambient binding therefore cannot coerce,
438-
reject, or otherwise alter a value the analyzer presents as an exact string.
434+
case-insensitively collide with PowerShell's automatic, constant, read-only,
435+
typed, validated, preference, or configuration variables are eligible.
436+
Scoped/provider bindings such as `$global:x`, `$script:x`, `$private:x`, and
437+
`$env:X` fail the loop region closed. A built-in or ambient binding therefore
438+
cannot coerce, reject, or otherwise alter a value the analyzer presents as an
439+
exact string, or change host behavior as a side effect of loop assignment.
440+
The supported preference-variable inventory is pinned to PowerShell's
441+
`about_Preference_Variables` reference rather than inferred only from variables
442+
materialized by a fresh host; lazy and configuration-dependent names remain
443+
ineligible even when `Get-Variable` does not initially enumerate them.
439444

440445
Parenthesized groups, `$()`, and static `Invoke-Expression` execute in the
441446
current runspace and share supported binding, command-resolution, and location

SPEC.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -297,12 +297,17 @@ does not establish this contract. A fixed bootstrap may establish these
297297
constraints only when it cannot define or mutate loop-bound variables or
298298
policy-relevant command identities.
299299

300-
The mode does not erase PowerShell's built-in automatic variables. Exact and
301-
finite binding proofs remain limited to
302-
ordinary unscoped variable names that do not collide, case-insensitively, with
303-
automatic, constant, or read-only bindings known to the supported PowerShell
304-
runtime. Scoped/provider forms such as `$global:x`, `$script:x`, and `$env:X`
305-
are outside the bounded loop-binding grammar.
300+
The mode does not erase PowerShell's built-in variable state. Exact and finite
301+
binding proofs remain limited to ordinary unscoped variable names that do not
302+
collide, case-insensitively, with automatic, constant, read-only, typed,
303+
validated, preference, or configuration bindings known to the supported
304+
PowerShell runtime. A `foreach` assignment to a built-in preference variable
305+
can coerce an authored string into an enum or reject it, and can change host
306+
behavior independently of the loop value; it is therefore not an ordinary
307+
string binding. Documented preference names remain excluded even when they are
308+
lazy or configuration-dependent and therefore absent from a fresh
309+
`Get-Variable` inventory. Scoped/provider forms such as `$global:x`, `$script:x`, and
310+
`$env:X` are outside the bounded loop-binding grammar.
306311

307312
The assertion applies only to the host that the caller actually constrains.
308313
Current-runspace regions such as `( ... )`, `$()`, and a static

openspec/changes/v0-3-structured-shell-analysis/design.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -440,8 +440,13 @@ read-only, or scoped; aliases, functions, and modules can independently change
440440
command identity. Syntax alone cannot erase any of those facts.
441441

442442
The positive binding grammar therefore accepts only ordinary unscoped names
443-
that do not case-insensitively collide with automatic, constant, or read-only
444-
variables known to the supported runtime. Current-runspace groups, `$()`, and
443+
that do not case-insensitively collide with automatic, constant, read-only,
444+
typed, validated, preference, or configuration variables known to the
445+
supported runtime. Built-in preference variables are excluded because
446+
assignment can coerce or reject authored strings or alter host behavior. The
447+
reviewed documented inventory is pinned independently from the live fresh-host
448+
oracle so lazy and configuration-dependent preferences cannot escape the gate.
449+
Current-runspace groups, `$()`, and
445450
static `Invoke-Expression` share supported binding, command-resolution, and cwd
446451
state. A decoded child `pwsh` host starts at `Unknown` unless its own invocation
447452
independently proves the complete constrained-host contract. Recognized

openspec/changes/v0-3-structured-shell-analysis/specs/bounded-shell-analysis/spec.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,11 @@ MAY establish those constraints only when it cannot define or mutate loop-bound
185185
variables or policy-relevant command identities. `-NoProfile -NonInteractive`
186186
alone SHALL NOT satisfy the contract. Exact and finite
187187
binding analysis SHALL remain limited to ordinary unscoped names that do not
188-
case-insensitively collide with automatic, constant, or read-only variables.
188+
case-insensitively collide with automatic, constant, read-only, typed,
189+
validated, preference, or configuration variables known to the supported
190+
PowerShell runtime. The preference inventory SHALL include documented lazy and
191+
configuration-dependent names even when a fresh `Get-Variable` inventory omits
192+
them.
189193
Scoped/provider binding forms SHALL fail closed.
190194

191195
Current-runspace groups, `$()`, and static `Invoke-Expression` payloads SHALL
@@ -211,6 +215,11 @@ initial-state assertion.
211215
- **THEN** default-mode analysis does not claim the authored strings are the effective loop values
212216
- **THEN** selecting isolated mode for that reused runspace would violate the caller contract
213217

218+
#### Scenario: Built-in preference binding is not an ordinary string slot
219+
- **WHEN** isolated-mode PowerShell parses a loop binding named `ConfirmPreference`, `ErrorActionPreference`, or another known built-in preference or configuration variable
220+
- **THEN** the complete loop region is unparseable
221+
- **THEN** the analyzer does not assume assignment avoids type coercion, validation, rejection, or host-behavior changes
222+
214223
#### Scenario: Child host does not inherit the parent's assertion
215224
- **WHEN** isolated-mode PowerShell parses a supported `pwsh -NoProfile -Command` child containing a `foreach`
216225
- **THEN** the child receives `Unknown` initial state unless the child invocation independently proves the complete constrained-host environment

openspec/changes/v0-3-structured-shell-analysis/tasks.md

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,18 @@
133133
and module baseline, current-runspace sharing, child-host noninheritance,
134134
mutation invalidation, and ambient typed/read-only binding hazards in the
135135
canonical specs and case-specific design corpus.
136-
- [ ] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings.
136+
- [x] 7.3 Derive exact and finite string domains without treating pipeline objects as literal strings.
137+
- The PowerShell-specific value pass consumes lexer provenance, composes
138+
case-insensitive distinct active bindings, publishes bounded literal
139+
scalar/array domains only under the explicit isolated-runspace contract,
140+
and collapses object, null, unsupported, and over-cap values to Unknown.
141+
The internal plan retains ordered duplicate visits and an exact authored
142+
count separately from its public set summary; unknown object iterables are
143+
zero-or-more. Reserved or stateful built-in binding collisions fail
144+
atomically; a pinned documented preference inventory covers lazy names and a
145+
live PowerShell 7.x oracle guards the fresh-host inventory. Decoded
146+
child hosts, current-runspace wrappers, redirect values, same-name nested
147+
overwrites, and post-loop state remain conservative for tasks 7.4-7.6.
137148
- [ ] 7.4 Propagate PowerShell scope and location state according to the locked statement semantics.
138149
- [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries.
139150
- [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow.

src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachStructuralParser.cs

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,14 @@ private bool TryParseForEach(
5353
}
5454

5555
var bindingToken = _tokens[_position++];
56+
if (_options.InitialStateMode ==
57+
PwshInitialStateMode.IsolatedNonInteractiveNoProfile &&
58+
!PwshForEachValueAnalysis.IsEligibleBindingName(bindingName))
59+
{
60+
error = "PowerShell foreach binding collides with a reserved or stateful built-in variable";
61+
return false;
62+
}
63+
5664
if (_position == _tokens.Count ||
5765
_tokens[_position].Kind != PwshTokenKind.Word ||
5866
!string.Equals(
@@ -74,6 +82,7 @@ private bool TryParseForEach(
7482
}
7583

7684
var iterableTokens = CopyTokens(iterableStart, closePosition);
85+
var isLiteralIterable = IsLiteralForEachExpression(iterableTokens);
7786
var firstIterable = iterableTokens[0];
7887
var lastIterable = iterableTokens[iterableTokens.Count - 1];
7988
var iterableSourceStart = firstIterable.SourceStart;
@@ -110,7 +119,7 @@ private bool TryParseForEach(
110119
return false;
111120
}
112121

113-
command = new ForEachSyntax
122+
var forEach = new ForEachSyntax
114123
{
115124
Binding = new LoopBindingSyntax
116125
{
@@ -136,6 +145,13 @@ private bool TryParseForEach(
136145
SourceLength = bodyToken.SourceStart + bodyToken.SourceLength -
137146
start.SourceStart,
138147
};
148+
_forEachPlans.Add(
149+
forEach,
150+
PwshForEachValueAnalysis.CapturePlan(
151+
bindingName,
152+
iterableTokens,
153+
isLiteralIterable));
154+
command = forEach;
139155
error = null;
140156
return true;
141157
}
@@ -267,7 +283,13 @@ private bool TryParseForEachIterator(
267283
sourceLength,
268284
CompoundOperator.None,
269285
insideCommandSubstitution: false);
270-
return coordinator.TryParse(out iterator, out error);
286+
if (!coordinator.TryParse(out iterator, out error))
287+
{
288+
return false;
289+
}
290+
291+
MergeFacts(coordinator);
292+
return true;
271293
}
272294

273295
private bool TryParseForEachBody(
@@ -318,7 +340,13 @@ private bool TryParseForEachBody(
318340
sourceLength,
319341
CompoundOperator.None,
320342
insideCommandSubstitution: false);
321-
return coordinator.TryParse(out body, out error);
343+
if (!coordinator.TryParse(out body, out error))
344+
{
345+
return false;
346+
}
347+
348+
MergeFacts(coordinator);
349+
return true;
322350
}
323351

324352
private static bool TryReadSimpleLoopBinding(PwshToken token, out string name)

0 commit comments

Comments
 (0)