From 4602c79c0b809716024039fe3d1beb9e7a3ac6e1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Fri, 7 Aug 2026 15:01:34 +0000 Subject: [PATCH] feat(pwsh): derive bounded foreach values --- IMPLEMENTATION_PLAN.md | 14 +- SPEC.POWERSHELL.md | 15 +- SPEC.md | 17 +- .../v0-3-structured-shell-analysis/design.md | 9 +- .../specs/bounded-shell-analysis/spec.md | 11 +- .../v0-3-structured-shell-analysis/tasks.md | 13 +- .../Parsing/PwshForEachStructuralParser.cs | 34 +- .../Pwsh/Parsing/PwshForEachValueAnalysis.cs | 839 ++++++++++++++++++ .../Pwsh/Parsing/PwshStructuralCoordinator.cs | 150 +++- src/ShellSyntaxTree/ShellSyntaxProjection.cs | 2 + .../Corpus/CorpusRunnerTests.cs | 41 +- .../Corpus/PwshOracleTests.cs | 34 + .../342_v03_foreach_literal_array.json | 12 +- .../343_v03_foreach_pipeline_iterator.json | 12 +- ...44_v03_foreach_subexpression_iterator.json | 12 +- .../345_v03_foreach_body_pipeline.json | 12 +- .../350_v03_foreach_semicolon_boundary.json | 12 +- ...v03_foreach_following_and_or_pipeline.json | 12 +- ...362_v03_foreach_isolated_exact_scalar.json | 41 + ...363_v03_foreach_isolated_finite_array.json | 40 + ...3_foreach_isolated_nested_composition.json | 42 + ..._v03_foreach_pipeline_objects_unknown.json | 59 ++ ...03_foreach_candidate_overflow_unknown.json | 40 + ...7_v03_foreach_automatic_binding_gated.json | 11 + .../368_v03_foreach_null_object_unknown.json | 40 + .../DesignCorpus/v0.3/powershell.json | 1 + .../Parsing/PwshForEachStructuralTests.cs | 184 ++++ .../Parsing/PwshForEachValueAnalysisTests.cs | 97 ++ tools/PwshCorpusTool/PwshOracle.cs | 36 +- 29 files changed, 1784 insertions(+), 58 deletions(-) create mode 100644 src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshForEachValueAnalysis.cs create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/362_v03_foreach_isolated_exact_scalar.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/363_v03_foreach_isolated_finite_array.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/364_v03_foreach_isolated_nested_composition.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/365_v03_foreach_pipeline_objects_unknown.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/366_v03_foreach_candidate_overflow_unknown.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/367_v03_foreach_automatic_binding_gated.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/368_v03_foreach_null_object_unknown.json create mode 100644 tests/ShellSyntaxTree.Tests/Parsing/PwshForEachValueAnalysisTests.cs 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 CorpusEntries() { @@ -607,6 +614,8 @@ public sealed record CorpusEntry public string Input { get; init; } = ""; + public PwshInitialStateMode? PowerShellInitialStateMode { get; init; } + public ExpectedParsedCommand? Expected { get; init; } public string? Notes { get; init; } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs index f86b27a..55eaccf 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/PwshOracleTests.cs @@ -9,6 +9,7 @@ using System.Linq; using System.Text.Json; using ShellSyntaxTree.Internal.Pwsh.Verbs; +using ShellSyntaxTree.Internal.Pwsh.Parsing; using ShellSyntaxTree.Tools.PwshCorpus; using Xunit; using Xunit.Sdk; @@ -114,6 +115,39 @@ public void PwshAliases_table_covers_every_live_alias() } } + [Fact] + public void Foreach_binding_boundary_covers_every_fresh_host_variable() + { + if (!PwshOracle.IsAvailable()) + { + Console.WriteLine("pwsh not on PATH — the foreach binding gate is skipped locally."); + return; + } + + var live = PwshOracle.GetVariableNames(); + Assert.NotNull(live); + + var gaps = live! + .Where(IsSimpleLoopBindingName) + .Where(PwshForEachValueAnalysis.IsEligibleBindingName) + .OrderBy(name => name, StringComparer.OrdinalIgnoreCase) + .ToArray(); + Assert.True( + gaps.Length == 0, + "The isolated foreach binding boundary is missing fresh-host variables: " + + string.Join(", ", gaps)); + } + + private static bool IsSimpleLoopBindingName(string name) + { + if (name.Length == 0 || name[0] != '_' && !char.IsLetter(name[0])) + { + return false; + } + + return name.Skip(1).All(character => character == '_' || char.IsLetterOrDigit(character)); + } + private static List<(string File, CorpusEntry Entry)> LoadPowershellCorpus() { var dir = Path.Combine(AppContext.BaseDirectory, "Corpus", "powershell"); diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json index ef6a7a8..a62bbea 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/342_v03_foreach_literal_array.json @@ -116,7 +116,17 @@ "sourceLength": 29 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 2, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json index ffac9a3..4a6338f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/343_v03_foreach_pipeline_iterator.json @@ -177,7 +177,17 @@ "sourceLength": 29 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 2, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json index fa02b54..d10166e 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/344_v03_foreach_subexpression_iterator.json @@ -208,7 +208,17 @@ "sourceLength": 17 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json index 6890f08..f45e039 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/345_v03_foreach_body_pipeline.json @@ -148,7 +148,17 @@ "sourceLength": 29 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json index 0a9656b..f355ca7 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/350_v03_foreach_semicolon_boundary.json @@ -195,7 +195,17 @@ "sourceLength": 17 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json index 12479fc..d26ccf5 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/352_v03_foreach_following_and_or_pipeline.json @@ -167,7 +167,17 @@ "sourceLength": 17 } ], - "effectiveArguments": [], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [], + "pattern": null, + "coveringDirectory": null + } + } + ], "workingDirectory": { "kind": "Unknown", "values": [], diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/362_v03_foreach_isolated_exact_scalar.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/362_v03_foreach_isolated_exact_scalar.json new file mode 100644 index 0000000..2c1c591 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/362_v03_foreach_isolated_exact_scalar.json @@ -0,0 +1,41 @@ +{ + "name": "V03 foreach isolated exact scalar", + "input": "foreach ($f in 'a.txt') { Remove-Item -LiteralPath $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Remove-Item"], + "args": [ + { "raw": "-LiteralPath", "kind": "Literal", "isPath": false }, + { "raw": "$f", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 55 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 55 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 25, "sourceLength": 29 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 2, + "value": { + "kind": "Exact", + "values": ["a.txt"] + } + } + ] + } + ] + }, + "notes": "The explicit constrained-host contract permits one exact loop binding proof." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/363_v03_foreach_isolated_finite_array.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/363_v03_foreach_isolated_finite_array.json new file mode 100644 index 0000000..5245daa --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/363_v03_foreach_isolated_finite_array.json @@ -0,0 +1,40 @@ +{ + "name": "V03 foreach isolated finite array", + "input": "foreach ($f in @('a.txt', 'b.txt', 'a.txt')) { Write-Output $F }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "$F", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 64 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 64 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 46, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "FiniteSet", + "values": ["a.txt", "b.txt"] + } + } + ] + } + ] + }, + "notes": "PowerShell variable lookup is case-insensitive and the public finite domain removes duplicates." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/364_v03_foreach_isolated_nested_composition.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/364_v03_foreach_isolated_nested_composition.json new file mode 100644 index 0000000..87a366e --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/364_v03_foreach_isolated_nested_composition.json @@ -0,0 +1,42 @@ +{ + "name": "V03 foreach isolated nested composition", + "input": "foreach ($outer in 'left') { foreach ($inner in @('a','b')) { Write-Output \"$OUTER/$Inner\" } }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "\"$OUTER/$Inner\"", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 94 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 94 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 28, "sourceLength": 65 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 29, "sourceLength": 63 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 61, "sourceLength": 30 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "FiniteSet", + "values": ["left/a", "left/b"] + } + } + ] + } + ] + }, + "notes": "Distinct active bindings compose without evaluating PowerShell expressions." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/365_v03_foreach_pipeline_objects_unknown.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/365_v03_foreach_pipeline_objects_unknown.json new file mode 100644 index 0000000..32808d2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/365_v03_foreach_pipeline_objects_unknown.json @@ -0,0 +1,59 @@ +{ + "name": "V03 foreach pipeline objects unknown", + "input": "foreach ($f in Get-ChildItem C:\\input) { Write-Output $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Get-ChildItem"], + "args": [ + { "raw": "C:\\input", "kind": "Literal", "isPath": true, "resolved": "C:/input" } + ], + "redirects": [] + }, + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "$f", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "Iterator", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 58 }, + { "ancestorKind": "ForEach", "region": "Iterator", "childIndex": null, "sourceStart": 0, "sourceLength": 58 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 15, "sourceLength": 22 } + ], + "effectiveArguments": [] + }, + { + "clauseIndex": 1, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 58 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 58 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 40, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [] + } + } + ] + } + ] + }, + "notes": "Pipeline output remains an object domain and is never guessed into strings." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/366_v03_foreach_candidate_overflow_unknown.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/366_v03_foreach_candidate_overflow_unknown.json new file mode 100644 index 0000000..95fb2f5 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/366_v03_foreach_candidate_overflow_unknown.json @@ -0,0 +1,40 @@ +{ + "name": "V03 foreach candidate overflow unknown", + "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 }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "$f", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 236 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 236 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 218, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [] + } + } + ] + } + ] + }, + "notes": "The candidate cap collapses atomically to Unknown and never truncates the set." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/367_v03_foreach_automatic_binding_gated.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/367_v03_foreach_automatic_binding_gated.json new file mode 100644 index 0000000..fb7af36 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/367_v03_foreach_automatic_binding_gated.json @@ -0,0 +1,11 @@ +{ + "name": "V03 foreach automatic binding gated", + "input": "foreach ($HOME in @('x')) { Write-Output $HOME }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": true, + "unparseableReasonContains": "built-in" + }, + "oracleExpectation": "OutOfScope", + "notes": "A fresh host still has built-in automatic and read-only variables." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/368_v03_foreach_null_object_unknown.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/368_v03_foreach_null_object_unknown.json new file mode 100644 index 0000000..0b9f5db --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/368_v03_foreach_null_object_unknown.json @@ -0,0 +1,40 @@ +{ + "name": "V03 foreach null object unknown", + "input": "foreach ($f in @($null)) { Write-Output $f }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["Write-Output"], + "args": [ + { "raw": "$f", "kind": "EnvVar", "isPath": false } + ], + "redirects": [] + } + ], + "commands": [ + { + "clauseIndex": 0, + "immediateRole": "LoopBody", + "isComplete": true, + "ancestry": [ + { "ancestorKind": "Block", "region": "Root", "childIndex": 0, "sourceStart": 0, "sourceLength": 44 }, + { "ancestorKind": "ForEach", "region": "LoopBody", "childIndex": null, "sourceStart": 0, "sourceLength": 44 }, + { "ancestorKind": "Block", "region": "Statement", "childIndex": 0, "sourceStart": 26, "sourceLength": 17 } + ], + "effectiveArguments": [ + { + "clauseElementIndex": 1, + "value": { + "kind": "Unknown", + "values": [] + } + } + ] + } + ] + }, + "notes": "An array containing null visits once but proves no string value." +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index bcab218..96797f6 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -886,6 +886,7 @@ { "id": "pwsh-foreach-automatic-variable-collision", "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "compatibilityProjectionLanded": true, "concern": "Fresh process mode does not make automatic read-only variables writable", "input": "foreach ($HOME in @('x')) { Write-Output $HOME }", "current": { "isUnparseable": false }, diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs index c7900ea..556cf26 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachStructuralTests.cs @@ -273,6 +273,21 @@ public void Isolated_child_host_loop_does_not_taint_outer_continuation() Assert.True(result.Commands[1].IsComplete); } + [Fact] + public void Decoded_child_host_pipeline_iterator_stays_visible_without_outer_plan() + { + var result = ParseIsolated( + "pwsh -Command 'foreach ($x in Get-Item C:\\input) " + + "{ Write-Output $x }'; Get-Date"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(new[] { "Get-Item", "Write-Output", "Get-Date" }, + result.Commands.Select(CommandVerb)); + Assert.True(result.Commands[0].IsComplete); + Assert.False(result.Commands[1].IsComplete); + Assert.True(result.Commands[2].IsComplete); + } + [Fact] public void Foreach_object_alias_remains_an_opaque_script_block_argument() { @@ -298,6 +313,158 @@ public void Foreach_shares_the_structural_depth_budget() Assert.Contains("nesting depth", overflow.UnparseableReason!); } + [Fact] + public void Isolated_literal_scalar_publishes_exact_binding_value() + { + var result = ParseIsolated( + "foreach ($f in 'a.txt') { Remove-Item -LiteralPath $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + var effective = Assert.Single(command.EffectiveArguments); + Assert.Equal("$f", command.Clause.Elements[effective.ClauseElementIndex].Raw); + AssertDomain(effective.Value, ShellValueDomainKind.Exact, "a.txt"); + } + + [Fact] + public void Isolated_literal_array_publishes_distinct_finite_binding_values() + { + var result = ParseIsolated( + "foreach ($f in @('a.txt', 'b.txt', 'a.txt')) { Write-Output $F }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.FiniteSet, + "a.txt", + "b.txt"); + } + + [Fact] + public void Default_initial_state_withholds_binding_proof() + { + var result = Parse( + "foreach ($f in @('a.txt', 'b.txt')) { Remove-Item -LiteralPath $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.False(command.IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind); + } + + [Fact] + public void Pipeline_objects_remain_unknown_without_making_body_structure_incomplete() + { + var result = ParseIsolated( + "foreach ($f in Get-ChildItem C:\\input) { Write-Output $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.True(result.Commands[0].IsComplete); + Assert.True(result.Commands[1].IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(result.Commands[1].EffectiveArguments).Value.Kind); + } + + [Fact] + public void Nested_distinct_bindings_compose_case_insensitively() + { + var result = ParseIsolated( + "foreach ($outer in 'left') { foreach ($inner in @('a','b')) " + + "{ Write-Output \"$OUTER/$Inner\" } }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + AssertDomain( + Assert.Single(command.EffectiveArguments).Value, + ShellValueDomainKind.FiniteSet, + "left/a", + "left/b"); + } + + [Fact] + public void Same_name_nested_binding_does_not_restore_outer_parser_frame() + { + var result = ParseIsolated( + "foreach ($f in 'outer') { foreach ($F in 'inner') " + + "{ Write-Output $f }; Write-Output $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.Equal(2, result.Commands.Count); + Assert.All(result.Commands, command => Assert.False(command.IsComplete)); + Assert.All(result.Commands, command => Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(command.EffectiveArguments).Value.Kind)); + } + + [Fact] + public void Null_and_candidate_overflow_remain_unknown() + { + var nullResult = ParseIsolated( + "foreach ($f in @($null)) { Write-Output $f }"); + var candidates = string.Join(",", Enumerable.Range(1, 33) + .Select(index => $"'v{index:00}'")); + var overflow = ParseIsolated( + $"foreach ($f in @({candidates})) {{ Write-Output $f }}"); + + Assert.False(nullResult.IsUnparseable, nullResult.UnparseableReason); + Assert.True(Assert.Single(nullResult.Commands).IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(Assert.Single(nullResult.Commands).EffectiveArguments).Value.Kind); + Assert.False(overflow.IsUnparseable, overflow.UnparseableReason); + Assert.True(Assert.Single(overflow.Commands).IsComplete); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(Assert.Single(overflow.Commands).EffectiveArguments).Value.Kind); + } + + [Theory] + [InlineData("HOME")] + [InlineData("home")] + [InlineData("PSItem")] + [InlineData("EnabledExperimentalFeatures")] + [InlineData("PSStyle")] + [InlineData("ConfirmPreference")] + [InlineData("_")] + public void Isolated_reserved_or_stateful_builtin_binding_fails_atomically(string binding) + { + var result = ParseIsolated( + $"foreach (${binding} in @('x')) {{ Write-Output ${binding} }}"); + + Assert.True(result.IsUnparseable); + Assert.Empty(result.Commands); + Assert.Empty(result.Clauses); + Assert.Contains("built-in", result.UnparseableReason!); + } + + [Fact] + public void Literal_variable_spelling_does_not_receive_effective_binding_value() + { + var result = ParseIsolated( + "foreach ($f in 'value') { Write-Output '$f' \"`$f\" }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + var command = Assert.Single(result.Commands); + Assert.True(command.IsComplete); + Assert.Empty(command.EffectiveArguments); + } + + [Fact] + public void Redirect_binding_stays_incomplete_until_redirect_analysis_lands() + { + var result = ParseIsolated("foreach ($f in 'out.txt') { Write-Output x > $f }"); + + Assert.False(result.IsUnparseable, result.UnparseableReason); + Assert.False(Assert.Single(result.Commands).IsComplete); + } + private static ParsedCommand Parse(string source) => new PwshParser( new PwshParserOptions { @@ -305,6 +472,23 @@ public void Foreach_shares_the_structural_depth_budget() WorkingDirectory = "C:/work", }).Parse(source); + private static ParsedCommand ParseIsolated(string source) => new PwshParser( + new PwshParserOptions + { + HomeDirectory = "C:/Users/test", + WorkingDirectory = "C:/work", + InitialStateMode = PwshInitialStateMode.IsolatedNonInteractiveNoProfile, + }).Parse(source); + + private static void AssertDomain( + ShellValueDomain domain, + ShellValueDomainKind kind, + params string[] values) + { + Assert.Equal(kind, domain.Kind); + Assert.Equal(values, domain.Values); + } + private static string CommandVerb(CommandOccurrence command) => command.Clause.Verb.Joined; private static string NestedLoops(int depth) diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachValueAnalysisTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachValueAnalysisTests.cs new file mode 100644 index 0000000..37f1a74 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshForEachValueAnalysisTests.cs @@ -0,0 +1,97 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Linq; +using ShellSyntaxTree.Internal.Pwsh.Lexing; +using ShellSyntaxTree.Internal.Pwsh.Parsing; +using Xunit; + +namespace ShellSyntaxTree.Tests.Parsing; + +public class PwshForEachValueAnalysisTests +{ + [Fact] + public void Documented_preference_variables_are_ineligible_bindings() + { + var documented = new[] + { + "ConfirmPreference", "DebugPreference", "ErrorActionPreference", "ErrorView", + "FormatEnumerationLimit", "InformationPreference", "LogCommandHealthEvent", + "LogCommandLifecycleEvent", "LogEngineHealthEvent", "LogEngineLifecycleEvent", + "LogProviderHealthEvent", "LogProviderLifecycleEvent", "MaximumHistoryCount", + "OFS", "OutputEncoding", "ProgressPreference", "PSDefaultParameterValues", + "PSEmailServer", "PSModuleAutoLoadingPreference", + "PSNativeCommandArgumentPassing", "PSNativeCommandUseErrorActionPreference", + "PSSessionApplicationName", "PSSessionConfigurationName", "PSSessionOption", + "PSStyle", "Transcript", "VerbosePreference", "WarningPreference", + "WhatIfPreference", + }; + + Assert.All( + documented, + name => Assert.False(PwshForEachValueAnalysis.IsEligibleBindingName(name))); + } + + [Fact] + public void Literal_array_plan_preserves_authored_order_and_duplicates() + { + var plan = Capture("@('a','b','a')", isLiteralExpression: true); + + Assert.Equal(PwshIterationCardinality.OneOrMore, plan.Cardinality); + Assert.Equal(3, plan.AuthoredVisitCount); + Assert.False(plan.RequiresFixedPoint); + Assert.Equal( + new[] { "a", "b", "a" }, + plan.OrderedCandidates.Select(candidate => Assert.Single(candidate.Values))); + Assert.Equal(ShellValueDomainKind.FiniteSet, plan.Summary.Kind); + Assert.Equal(new[] { "a", "b" }, plan.Summary.Values); + } + + [Fact] + public void Ordered_visit_overflow_retains_count_without_truncating_sequence() + { + var values = string.Join(",", Enumerable.Repeat("'same'", 33)); + var plan = Capture($"@({values})", isLiteralExpression: true); + + Assert.Equal(33, plan.AuthoredVisitCount); + Assert.True(plan.RequiresFixedPoint); + Assert.Empty(plan.OrderedCandidates); + Assert.Equal(ShellValueDomainKind.Exact, plan.Summary.Kind); + Assert.Equal("same", Assert.Single(plan.Summary.Values)); + } + + [Fact] + public void Single_command_iterator_has_unknown_zero_or_more_cardinality() + { + var plan = Capture("Get-ChildItem", isLiteralExpression: false); + + Assert.Equal(PwshIterationCardinality.ZeroOrMore, plan.Cardinality); + Assert.Null(plan.AuthoredVisitCount); + Assert.True(plan.RequiresFixedPoint); + Assert.Empty(plan.OrderedCandidates); + Assert.Equal(ShellValueDomainKind.Unknown, plan.Summary.Kind); + } + + [Fact] + public void Non_string_literal_retains_one_unknown_authored_visit() + { + var plan = Capture("1", isLiteralExpression: true); + + Assert.Equal(PwshIterationCardinality.OneOrMore, plan.Cardinality); + Assert.Equal(1, plan.AuthoredVisitCount); + Assert.False(plan.RequiresFixedPoint); + Assert.Equal( + ShellValueDomainKind.Unknown, + Assert.Single(plan.OrderedCandidates).Kind); + } + + private static PwshForEachAnalysisPlan Capture( + string source, + bool isLiteralExpression) => + PwshForEachValueAnalysis.CapturePlan( + "f", + PwshLexer.Tokenize(source), + isLiteralExpression); +} diff --git a/tools/PwshCorpusTool/PwshOracle.cs b/tools/PwshCorpusTool/PwshOracle.cs index 2ede011..ea462d1 100644 --- a/tools/PwshCorpusTool/PwshOracle.cs +++ b/tools/PwshCorpusTool/PwshOracle.cs @@ -80,8 +80,8 @@ public static bool IsAvailable() => } finally { - TryDelete(scriptPath); - TryDelete(inputPath); + _ = TryDelete(scriptPath); + _ = TryDelete(inputPath); } } @@ -103,6 +103,25 @@ public static bool IsAvailable() => StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } + /// + /// The names of every variable a fresh no-profile pwsh process + /// defines. Returns null when pwsh is absent. + /// + public static IReadOnlyList? GetVariableNames() + { + if (!TryRunPwsh( + "-NoProfile -NoLogo -NonInteractive -Command \"Get-Variable | ForEach-Object Name\"", + 60000, + out var stdout)) + { + return null; + } + + return stdout.Split( + new[] { '\r', '\n' }, + StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + } + /// /// Run pwsh with and capture stdout. /// Returns false when pwsh is absent, the run times out, or it @@ -135,7 +154,7 @@ private static bool TryRunPwsh(string arguments, int timeoutMs, out string stdou if (!process.WaitForExit(timeoutMs)) { - KillQuietly(process); + _ = TryKill(process); return false; } @@ -148,19 +167,20 @@ private static bool TryRunPwsh(string arguments, int timeoutMs, out string stdou } } - private static void KillQuietly(Process process) + private static bool TryKill(Process process) { try { process.Kill(entireProcessTree: true); + return true; } catch (Exception ex) when (ex is InvalidOperationException or System.ComponentModel.Win32Exception) { - // The child already exited, or could not be killed — best effort. + return false; } } - private static void TryDelete(string path) + private static bool TryDelete(string path) { try { @@ -168,10 +188,12 @@ private static void TryDelete(string path) { File.Delete(path); } + + return true; } catch (IOException) { - // Best-effort temp cleanup. + return false; } } }