diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index c16473b..d87ec4b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -351,6 +351,24 @@ priorities. call-operator, dynamic-identity, and host-wrapper positions, with current-scope state propagation and bounded expression rejection pinned by the 372-entry executable corpus. +- [ ] Deliver typed PowerShell script-block execution regions before calling + tasks 7.5-7.7 complete. The corrected contract adds an execution-region + syntax node with independent origin, phase, timing, and cardinality rather than a + false shared/isolated scope flag. The inert additive public API skeleton, + enum/default snapshots, recorded local PowerShell probe evidence, and + design-corpus categories are delivered; no parser emits a region yet and + automated execution-region oracle coverage remains in task 7.7. Continue in + small slices: projection; pinned receiver/parameter binding including + ForEach-Object multi-block phases; direct `&` / `.` and synchronous + current-runspace callbacks; child process/runspace jobs and parallel + blocks; deferred breakpoint/event/completion actions; then unknown + receiver and nested/adversarial matrices. Preserve script blocks proved + to be data as opaque values, expose ambiguous bodies with incomplete + facts, and fail atomically when any potentially executable interior is + unsupported. Local PowerShell 7.6.4 probes pin variable-versus-location + independence, semantic phase order, child process/runspace boundaries, + registration-versus-trigger timing, and the fact that the in-process + `Invoke-Command` parameter set does not support `-AsJob`. - [ ] Deliver Bash `for ... in` and PowerShell `foreach` as the first two language-specific vertical slices, then extract only the shared analysis proven by both implementations. diff --git a/PROJECT_CONTEXT.md b/PROJECT_CONTEXT.md index 1899bae..968fe68 100644 --- a/PROJECT_CONTEXT.md +++ b/PROJECT_CONTEXT.md @@ -82,6 +82,12 @@ zero-native-deps .NET parser sized to what security gates actually need. - Add a library-owned command-occurrence projection for security consumers so every potentially executable iterator, condition, branch, substitution, and body command is evaluated exactly once. +- Add typed PowerShell execution regions for direct call/dot-source blocks, + synchronous callbacks, jobs/parallel runspaces, initialization, and deferred + actions. Public origin/phase/timing/cardinality facts remain separate from + shell-specific variable, location, command-resolution, runspace, and process + state analysis; proved script-block data stays opaque and unknown receivers + conservatively expose incomplete bodies. - Add fixed, non-executing value and state analysis: at most 32 candidates, at most 16 structural container levels, and the existing wrapper depth of 5. - Deliver Bash `for ... in` and PowerShell `foreach` first, then the locked diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index 197a35a..e19f21f 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -274,10 +274,12 @@ statement_sep := ";" | "&&" | "||" | NEWLINE statement := pipeline pipeline := pipeline_element ("|" pipeline_element)* pipeline_element := static_invocation | dynamic_invocation + | direct_script_block_invocation | supported_subexpression | grouped_pipeline static_invocation := call_op? command_name arg* redirect* dynamic_invocation := call_op dynamic_command_name arg* redirect* call_op := "&" // call operator at verb position +direct_script_block_invocation := ("&" | ".") script_block command_name := cmdlet | native_word // statically identified; // excludes variables, // quoted expressions, and $() @@ -286,7 +288,8 @@ parameter := "-" param_name (":" value)? // -Name value | -Name:value | "-" param_name // switch parameter | "--" // end-of-parameters marker value := word | quoted_string | here_string - | script_block // { ... } -> DynamicSkip Arg + | script_block // DynamicSkip Arg plus a proved, + // unknown, or absent execution region | supported_subexpression // $( ... ) -> child commands + DynamicSkip Arg | array_expression // @( ... ) -> DynamicSkip Arg | hash_literal // @{ ... } -> DynamicSkip Arg @@ -321,7 +324,10 @@ quoted_string := single_quoted | double_quoted - Backtick `` ` `` followed by a newline is a line continuation (treated as whitespace) — the PowerShell analog of bash `\` + newline. - `&` at verb position is the **call operator** (`& git status`, `& $exe`, - `& { ... }`). A *trailing* `&` (a PowerShell background job) marks + `& { ... }`). A direct `& { ... }` is a child-scope execution region; direct + `. { ... }` is a current-scope dot-source execution region. Neither operator + becomes a synthetic command occurrence. Dot-sourcing a file remains + unparseable because the file contents are unavailable. A *trailing* `&` (a PowerShell background job) marks `IsUnparseable` (§11). - Under v0.2 a variable, subexpression, quoted string, or script block at command position could be retained as a dynamic clause. Stable v0.3 aligns @@ -354,8 +360,10 @@ quoted_string := single_quoted | double_quoted v0.2 each was one opaque `DynamicSkip` arg. Stable v0.3 recursively parses every completely delimited executable `$()` in a supported value position and exposes its commands while retaining the containing authored - `DynamicSkip` leaf. Ordinary script-block literals remain non-executing - opaque values. An `@()` or `@{}` value with execution-bearing content is + `DynamicSkip` leaf. Script blocks are classified after command and parameter + binding: cataloged execution-bearing bindings create typed regions, + cataloged data bindings remain opaque, and unknown receivers create unknown + incomplete regions with visible bodies. An `@()` or `@{}` value with execution-bearing content is unparseable until that expression form has complete command discovery; a non-executing literal form may remain an opaque value. - Under v0.2, control-flow keywords fall outside the grammar. Stable v0.3 owns @@ -369,8 +377,11 @@ PowerShell retains a statement-versus-pipeline distinction. `foreach` is a language keyword only at statement position when followed by `(`; `Get-ChildItem | foreach { ... }` and `Write-Output x | foreach ($_)` remain command/alias syntax. An ordinary -script-block or bounded non-executing parenthesized argument remains opaque; it -is not reinterpreted as a loop body. `&&` and `||` join pipelines, not +bounded non-executing parenthesized argument remains opaque. A script-block +argument is classified by its proved receiver and parameter binding: executing +bindings create regions, proved data remains opaque, and unknown receivers +create unknown incomplete regions. It is not reinterpreted as a loop body. +`&&` and `||` join pipelines, not control-flow statements, so they cannot precede or follow `foreach`; `;` and newline remain legal statement terminators. @@ -404,15 +415,17 @@ foreach_expression := literal_value | supported_subexpression literal_array := "@(" literal_value ("," literal_value)* ")" script_block_body := "{" pwsh_script(stop = "}") "}" +direct_execution_region := ("&" | ".") script_block_body ``` Literal scalar and literal-array iterables may produce exact or finite string domains. A pipeline iterable exposes every producing command with role `Iterator`, but its object values remain `Unknown`; the parser does not predict -PowerShell object-to-string conversion. The body is recursively parsed only -after the structural grammar proves that the `ScriptBlock` token is the body -of a recognized statement. An ordinary script-block argument remains one -opaque `DynamicSkip` value and does not invent child execution. +PowerShell object-to-string conversion. A body is recursively parsed after the +structural grammar proves that the `ScriptBlock` token is a statement body, +direct execution region, cataloged execution-bearing argument, or conservative +unknown-receiver region. A proved non-executing script-block argument remains +one opaque `DynamicSkip` value and does not invent child execution. Publishing those exact or finite values also requires `PwshInitialStateMode.IsolatedNonInteractiveNoProfile`. The default `Unknown` @@ -488,9 +501,69 @@ command substitution in Bash command-name position contributes to the command word, so stable v0.3 makes that runtime-dependent identity unparseable rather than inventing a static or PowerShell-style dynamic clause. -`& { ... }` executes a script block and remains unparseable in stable v0.3 -until its body, scope, and state propagation are modeled. This differs from an -ordinary script-block argument, which remains a non-executing opaque value. +### Script-block execution regions + +`& { ... }` and `. { ... }` are direct execution regions. Their bodies are +recursively parsed and no synthetic outer command occurrence is created for +the invocation operator. Their typed origins are `DirectCall` and `DotSource` +respectively, so consumers never need to reparse source text to distinguish +their state behavior. `&` executes once synchronously in a child +variable/command scope while sharing runspace location; `.` executes once +synchronously in the current scope. Dot-sourcing a file remains unparseable +because the parser does not read `.ps1` contents. + +A script block passed to a command remains an authored `DynamicSkip` argument +on the host `Clause`. After canonical command and parameter binding, a proved +execution-bearing block additionally creates an attached +`ExecutionRegionSyntax`; a proved data block does not. An unknown receiver or +ambiguous binding conservatively creates an unknown incomplete region so every +body command remains visible. If that body cannot be parsed completely, the +whole result is unparseable. + +The version-pinned PowerShell 7 catalog covers: + +| Receiver / parameter | Phase | Timing | Cardinality | State boundary | +|---|---|---|---|---| +| direct `& {}` | Main | Synchronous | Once | child variables/commands; shared location | +| direct `. {}` | Main | Synchronous | Once | current scope and location | +| `ForEach-Object -Begin` | Begin | Synchronous | Once | current runspace | +| `ForEach-Object -Process` / `-RemainingScripts` | Process, with binder-assigned Begin/End where applicable | Synchronous | OncePerInputObject | current runspace | +| `ForEach-Object -End` | End | Synchronous | Once | current runspace | +| `ForEach-Object -Parallel` | Process | Concurrent | OncePerInputObject | child runspace; exit isolated | +| `Where-Object -FilterScript` | Filter | Synchronous | OncePerInputObject | current runspace | +| in-process `Invoke-Command -ScriptBlock` | Main | Synchronous | Once | child scope unless `-NoNewScope`; shared location | +| remote/session/SSH/VM/container `Invoke-Command` | Main | proved from complete parameter set | Unknown unless targets are proved | remote/child state; exit isolated | +| `Measure-Command -Expression`, `Trace-Command -Expression` | Main | Synchronous | Once | current scope and location | +| `Start-Job -InitializationScript` | Initialization | Concurrent | Once | child process before Main | +| `Start-Job -ScriptBlock` | Main | Concurrent | Once | child process; exit isolated | +| `New-Module -ScriptBlock` | Initialization | Synchronous | Once | module state; current-runspace effects analyzed separately | +| `Set-PSBreakpoint -Action`, event `-Action` | Action | Deferred | ZeroOrMore | trigger-time state Unknown without proof | +| `Register-ArgumentCompleter -ScriptBlock` | Completion | Deferred | ZeroOrMore | completion-time state Unknown without proof | + +The optional inbox `Microsoft.PowerShell.ThreadJob\Start-ThreadJob` follows +the initialization/main child-runspace model only when the caller's pinned +module baseline proves that identity. Otherwise it follows the unknown +receiver rule. + +Aliases, supported module-qualified spellings, static call-operator spellings, +parameter abbreviations and inline values, positional binding, parameter-set +selection, and `ScriptBlock[]` binding resolve through the same static catalog. +PowerShell's special multiple-script-block binding for `ForEach-Object` assigns +Begin, Process, and End phases semantically; authored syntax and occurrence +projection remain in source order while the analyzer schedules phases in +runtime order. + +`ExecutionRegionTiming` and `ExecutionRegionCardinality` are not scope facts. +Variable, location, command-resolution, runspace, and process propagation are +analyzed independently. Deferred actions remain authorization-visible at +registration, but relative paths use trigger-time Unknown cwd unless another +proof exists. A constrained canonical `Write-Output { Remove-Item x }` +remains opaque data and does not invent a `Remove-Item` occurrence. + +A leading `param(...)` declaration inside any execution region remains outside +the stable-v0.3 body grammar and makes the whole parse unparseable. This +deliberately limits realistic argument-completer and directly invoked blocks +until parameter declaration and block-argument binding are modeled together. `condition_pipeline` is limited to a pipeline the existing parser can delimit completely. A subexpression, member invocation, script block, or other form @@ -566,8 +639,10 @@ The `PwshLexer` produces tokens consumed by `PwshCommandParser`. Token kinds - **Continuation** — backtick + newline. Treated as whitespace. - **Comment** — `#` line comment to end-of-line, or `<# ... #>` block comment. Dropped by the significant-token filter. -- **ScriptBlock** — a balanced `{ ... }` region, emitted whole. Parser → - `DynamicSkip` arg. +- **ScriptBlock** — a balanced `{ ... }` region, emitted whole. Command + arguments retain a `DynamicSkip` compatibility arg. Structural binding may + additionally recurse into it as a direct, cataloged, or conservative unknown + execution region; proved data remains opaque. - **Subexpression** — a balanced `$( ... )`, `@( ... )`, or `@{ ... }` region, emitted whole. The v0.3 structural parser recursively lowers supported `$()` regions and retains the outer compatibility arg as @@ -941,7 +1016,7 @@ classifies as a path for canonical FileVerbs. Per-cmdlet overrides: | `New-Item` | positional 0 = path | | `Set-Content`, `Add-Content` | positional 0 = path; `-Value` is content | | `Select-String` | positional 0 = **pattern**, rest = paths (mirrors bash `grep`) | -| `ForEach-Object`, `Where-Object` | positional 0 is typically a script block → `DynamicSkip`; no path positionals | +| `ForEach-Object`, `Where-Object` | positional script blocks remain `DynamicSkip` compatibility args and additionally bind typed execution regions; no path positionals | The default for a canonical FileVerb with no override is "all non-flag positionals are paths," exactly as `SPEC.md` §7. @@ -1174,10 +1249,13 @@ supported executable `$()` interior into `SimpleCommandSyntax.Substitutions`. Execution-bearing `@()` / `@{}` forms that cannot be completely discovered make the whole result unparseable. Splatting `@var` remains `DynamicSkip`. The `--%` stop-parsing token makes the pipeline-element remainder one -`DynamicSkip` arg. An ordinary script-block argument remains non-executing -opaque data: `gci | ? { ... } | rm` is a clean three-clause pipeline whose -middle clause carries an opaque arg; consumers decide whether that command -interprets the block. +`DynamicSkip` arg. Script-block arguments additionally pass through the §4 +receiver/binding catalog. Thus `gci | ? { Test-Path $_ } | rm` retains a +three-clause compatibility pipeline, and the middle clause owns a Filter +execution region whose body commands are projected. A canonical receiver +proved to consume a block as data retains only the opaque arg. An unknown +receiver retains an unknown incomplete region rather than asking consumers to +decide whether hidden commands execute. PowerShell `$()` runs in the current runspace scope. A `Set-Location` inside a subexpression affects later inner commands, the containing command after value @@ -1313,13 +1391,17 @@ following binding and command-resolution proof and makes location attribution dynamic for every following relative path. The same rule applies to `iex`, a static call-operator spelling, and the supported module-qualified spelling. -The dot-source invocation operator and unsupported module-qualified cmdlets -are unparseable rather than being exposed under a misleading raw verb. This +The dot-source invocation operator remains unparseable except for one inline, +completely delimited script block. Dot-sourced files/dynamic values and +unsupported module-qualified cmdlets are unparseable rather than being exposed +under a misleading raw verb. This validation applies independently to every simple command inside structural lists, pipelines, loops, groups, and substitutions, including built-in cmdlets such as `Tee-Object` whose verb is not in the approved-verb table. The -one supported module-qualified wrapper remains -`Microsoft.PowerShell.Utility\Invoke-Expression`. A quoted string is a command +supported module-qualified exceptions are +`Microsoft.PowerShell.Utility\Invoke-Expression` and the version-pinned §4 +execution-region receiver catalog under its constrained command-resolution +contract. A quoted string is a command identity only when preceded by the call operator `&`; otherwise it is an unsupported expression. Any dynamic command identity invalidates following location attribution because it can resolve to current-scope code that calls @@ -1347,8 +1429,11 @@ in **`SPEC.md` §11**. 4. **Block / trap / data keywords** — `param`, `begin`, `process`, `end`, `dynamicparam`, `trap`, `data`, `try`, `catch`, `finally`. 5. **Statement keywords leading a statement** — `return`, `throw`, `break`, - `continue`, `exit` (including `exit 0`), `using`, `hidden`, and the - dot-source operator `.` used as a statement. + `continue`, `exit` (including `exit 0`), `using`, and `hidden`. The + call and dot-source operators support only one completely delimited inline + script block. `& { ... } arg` and `. { ... } arg` remain unparseable until + block-argument binding is modeled. `. ./script.ps1` and a dynamic dot-source + target remain unparseable because their executable content is unavailable. 6. **Trailing `&` background-job operator** — a `&` at the end of a pipeline (not at verb position). `& git status` is the call operator and parses; `git status &` is a background job and does not. diff --git a/SPEC.md b/SPEC.md index 3673aa4..2cb6353 100644 --- a/SPEC.md +++ b/SPEC.md @@ -178,9 +178,14 @@ public sealed record ConditionLoopSyntax : ShellSyntaxNode { ... } public sealed record ConditionalSyntax : ShellSyntaxNode { ... } public sealed record ConditionalBranchSyntax : ShellSyntaxNode { ... } public sealed record CommandSubstitutionSyntax : ShellSyntaxNode { ... } +public sealed record ExecutionRegionSyntax : ShellSyntaxNode { ... } public enum ShellSyntaxKind { ... } public enum ShellGroupKind { ... } public enum ConditionLoopKind { ... } +public enum ExecutionRegionOrigin { ... } +public enum ExecutionRegionPhase { ... } +public enum ExecutionRegionTiming { ... } +public enum ExecutionRegionCardinality { ... } // v0.3 authorization and bounded-analysis projections — see §3. public sealed record CommandOccurrence { ... } @@ -346,7 +351,9 @@ positional argument: the retained v0.2 `Arg.IsFlag` contract recognizes only an ASCII `-`, so normalizing the authored `Raw` spelling would either lose provenance or require a breaking API change. Unsupported module-qualified cmdlets are likewise rejected inside structural regions, not only in a flat -command; the existing module-qualified `Invoke-Expression` exception remains. +command. The existing module-qualified `Invoke-Expression` exception and the +version-pinned PowerShell execution-region receiver catalog are the only +specified exceptions. For a successful result, every authored simple command appears once in `Syntax`, once in `Commands`, and once in `Clauses`, with all three projections @@ -389,6 +396,7 @@ public enum ShellSyntaxKind Conditional, ConditionalBranch, CommandSubstitution, + ExecutionRegion, } public sealed record ShellBlockSyntax : ShellSyntaxNode @@ -402,6 +410,7 @@ public sealed record SimpleCommandSyntax : ShellSyntaxNode public override ShellSyntaxKind Kind => ShellSyntaxKind.SimpleCommand; public Clause Clause { get; init; } = new(); public IReadOnlyList Substitutions { get; init; } = []; + public IReadOnlyList ExecutionRegions { get; init; } = []; } public sealed record PipelineSyntax : ShellSyntaxNode @@ -492,6 +501,54 @@ public sealed record CommandSubstitutionSyntax : ShellSyntaxNode public override ShellSyntaxKind Kind => ShellSyntaxKind.CommandSubstitution; public ShellBlockSyntax Body { get; init; } = new(); } + +public sealed record ExecutionRegionSyntax : ShellSyntaxNode +{ + public override ShellSyntaxKind Kind => ShellSyntaxKind.ExecutionRegion; + public ExecutionRegionOrigin Origin { get; init; } + public int? HostClauseElementIndex { get; init; } + public ExecutionRegionPhase Phase { get; init; } + public ExecutionRegionTiming Timing { get; init; } + public ExecutionRegionCardinality Cardinality { get; init; } + public ShellBlockSyntax Body { get; init; } = new(); +} + +public enum ExecutionRegionOrigin +{ + Unknown, + DirectCall, + DotSource, + CommandArgument, +} + +public enum ExecutionRegionPhase +{ + Unknown, + Main, + Initialization, + Begin, + Process, + End, + Filter, + Action, + Completion, +} + +public enum ExecutionRegionTiming +{ + Unknown, + Synchronous, + Concurrent, + Deferred, +} + +public enum ExecutionRegionCardinality +{ + Unknown, + Once, + OncePerInputObject, + ZeroOrMore, +} ``` `ForEachSyntax` shares proved execution structure only. `Iterable.Raw` keeps @@ -527,6 +584,7 @@ public enum CommandOccurrenceRole LoopBody, Branch, Substitution, + ExecutionRegion, } public sealed record CommandAncestryFrame @@ -550,6 +608,7 @@ public enum CommandAncestryRegion Condition, Branch, Substitution, + ExecutionRegion, } public sealed record EffectiveArgument @@ -596,10 +655,32 @@ preserves nesting: a substitution inside an inner simple command belongs to that inner command, not to the outer command or a side table. `Clause` remains the unchanged v0.2 compatibility leaf and retains the authored dynamic value. +`SimpleCommandSyntax.ExecutionRegions` owns each completely delimited body +that a proved or conservatively unknown command binding may execute. The +unchanged host `Clause` retains its authored script-block `DynamicSkip` +argument. A direct PowerShell `& {}` or `. {}` region appears as a statement, +carries `Origin=DirectCall` or `Origin=DotSource`, has +`HostClauseElementIndex=null`, and creates no synthetic occurrence for the +invocation operator. A command-owned region carries `Origin=CommandArgument` +and the non-negative index of its script-block token in the host +`Clause.Elements`. Consumers use `Origin`, rather than reparsing source text, +to distinguish the different direct-invocation state semantics. + +Execution-region origin, phase, timing, and cardinality are independent +structural facts. Attached regions retain authored script-block order even when a +shell-specific analyzer schedules Begin/Process/End or initialization/main in +another semantic order. `Synchronous`, `Concurrent`, and `Deferred` do not +claim variable, cwd, command-resolution, runspace, or process scope. Those +dimensions remain shell-specific analysis because PowerShell can isolate +ordinary variable assignment while sharing location. Unknown enum values fail +closed. + The canonical `Commands` and compatibility `Clauses` projections use these deterministic ordering rules: disjoint executable regions follow authored source order; an enclosed substitution precedes its containing command; -nested substitutions are emitted innermost first; and nodes without comparable +nested substitutions are emitted innermost first; a command-owned execution +region follows its host and sibling regions retain authored order; and nodes +without comparable outer source spans use their containing structural collection order. Thus `rm "$(find /tmp)"` projects `find`, then `rm`, exactly once each. Each substitution ancestry frame uses `Region=Substitution` and its authored @@ -612,13 +693,14 @@ next node on the path. The root block uses `Root`; non-root blocks and command lists use `Statement`; pipelines use `PipelineStage`; groups use `GroupBody`; foreach nodes use `Iterator` or `LoopBody`; condition loops use `Condition` or `LoopBody`; conditionals use `Branch`; conditional-branch nodes use -`Condition` or `Branch`; and command substitutions use `Substitution`. +`Condition` or `Branch`; command substitutions use `Substitution`; and +execution regions use `ExecutionRegion`. Repeated children use their zero-based authored index. The `else` child uses the branch count, placing it after every condition/body pair. Frame source ranges belong to the ancestor. Blocks, command lists, and groups retain the incoming immediate role; pipeline stages, iterator/body regions, -condition/body regions, branches, and substitutions replace it with their -nearer execution role. +condition/body regions, branches, substitutions, and execution regions replace +it with their nearer execution role. Projection accepts only a parser-owned tree: a syntax-node or `Clause` reference cannot appear at two authored positions, node and fragment spans are @@ -647,7 +729,8 @@ The parser does not execute commands, inspect runtime variables, enumerate the filesystem, or truncate an over-limit set and call it complete. A result with 33 or more candidates becomes `Unknown`. Structural depth starts at zero for the root and increments on foreach loops, condition loops, conditionals, -groups, and command substitutions; blocks, lists, pipelines, branches, and +groups, command substitutions, and execution regions; blocks, lists, +pipelines, branches, and simple-command leaves do not independently increment it. Exceeding 16 structural containers or 5 decoded-command wrapper recursions makes the entire result unparseable. diff --git a/openspec/changes/v0-3-structured-shell-analysis/design.md b/openspec/changes/v0-3-structured-shell-analysis/design.md index 834a517..6178460 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/design.md +++ b/openspec/changes/v0-3-structured-shell-analysis/design.md @@ -25,6 +25,9 @@ incomplete executable regions never become authorization evidence. - Represent supported nested command structure for Bash and PowerShell. - Expose every command that may execute without requiring consumers to walk an evolving syntax-node hierarchy. +- Represent direct, callback, job, parallel, initialization, and deferred + PowerShell script-block execution without treating proved script-block data + as code. - Resolve constrained loop values and shell state only when bounded proof is possible. - Distinguish static redirect operations from dynamic redirect targets. @@ -41,6 +44,8 @@ incomplete executable regions never become authorization evidence. - Unify the Bash and PowerShell lexers or introduce a shared parser base class. - Treat Bash and PowerShell constructs as equivalent when their scoping, expansion, pipeline, or expression semantics differ. +- Collapse PowerShell variable, working-directory, command-resolution, + runspace, and process propagation into one shared/isolated scope flag. - Make every construct named by issue #71 part of the first implementation slice. - Classify URL-like arguments or environment assignments as harmless without @@ -328,6 +333,84 @@ opaque/dynamic value but cannot retain a nested executable tree. A substitution nested inside an inner command belongs to that inner simple command; it is not promoted to a sibling or stored in a side table. +Execution-bearing script blocks bound to that command are owned separately by +an authored-order `ExecutionRegions` collection. The unchanged `Clause` keeps +the script-block token as an opaque `DynamicSkip` argument for compatibility; +the execution-region node records why and how the block may run. A script block +proved to be data, such as the argument to canonical `Write-Output` in a +constrained command-resolution context, remains only the opaque argument and +does not gain a region. + +### Model PowerShell execution regions without a false scope union + +PowerShell script blocks are values whose receiver determines whether, when, +how often, and where they execute. The former contract treated every ordinary +script-block argument as inert. That is correct for `Write-Output { ... }` but +incorrect for `ForEach-Object`, `Where-Object`, `Invoke-Command`, jobs, +module initialization, event actions, breakpoints, and argument completers. +It also made `& { ... }` unparseable even though its body is statically +delimited. + +Stable v0.3 adds one `ExecutionRegionSyntax` node to the closed syntax family. +It exposes independent origin, phase, timing, and cardinality discriminants, +an optional owning `ClauseElement` coordinate, and the recursively parsed body. +The region may be attached to `SimpleCommandSyntax.ExecutionRegions` or appear +directly as a statement for `& {}` and `. {}`. Direct invocation operators are +shell syntax and do not create synthetic command occurrences; a command-owned +region retains both its host occurrence and every body occurrence. +`ExecutionRegionOrigin` distinguishes `DirectCall`, `DotSource`, and +`CommandArgument`, so consumers do not need source text to recover state-significant +invocation syntax. + +The public node deliberately omits one `Scope` or `Environment` enum. Local +PowerShell 7.6.4 probes demonstrate that these are independent dimensions: + +| Construct | Ordinary variable assignment | Location | Timing / cardinality | +|---|---|---|---| +| `& { ... }` | child scope; exit assignment isolated | shared | synchronous / once | +| `. { ... }` | shared current scope | shared | synchronous / once | +| `ForEach-Object { ... }` | shared current runspace | shared | synchronous / once per input | +| `Where-Object { ... }` | shared current runspace | shared | synchronous / once per input | +| local `Invoke-Command { ... }` | child scope unless `-NoNewScope` | shared | synchronous / once | +| `Measure-Command { ... }` / `Trace-Command -Expression { ... }` | shared current scope | shared | synchronous / once | +| `Start-Job { ... }` | child process state | inherited initial location; exit isolated | concurrent / once | +| `ForEach-Object -Parallel { ... }` | child runspace state | inherited initial location; exit isolated | concurrent / once per input | +| event, breakpoint, completion actions | trigger-time state | trigger-time state | deferred / zero or more | + +The shell-specific analyzer owns inbound state, exit propagation, phase +scheduling, success/failure partitions, and target/runspace differences. +Occurrence facts expose the effective result. This keeps the public structural +API narrow while avoiding the false claim that variables and location always +share one boundary. + +`SimpleCommandSyntax.ExecutionRegions` preserves authored script-block order. +The analyzer separately applies semantic phase order. PowerShell's binder can +assign Begin, Process, and End roles to multiple `ForEach-Object` blocks even +when parameter or positional order does not match runtime phase order. The +region's `HostClauseElementIndex` is therefore a correlation coordinate, not +an execution-order index. + +The pinned PowerShell 7 catalog covers direct call and dot-source blocks; +`ForEach-Object` Begin, Process, End, RemainingScripts, and Parallel; +`Where-Object -FilterScript`; `Invoke-Command -ScriptBlock`; +`Measure-Command -Expression`; `Trace-Command -Expression`; `Start-Job` +ScriptBlock and InitializationScript; `New-Module -ScriptBlock`; +`Set-PSBreakpoint -Action`; `Register-ObjectEvent -Action`; +`Register-EngineEvent -Action`; and `Register-ArgumentCompleter -ScriptBlock`. +The optional inbox `Start-ThreadJob` command is complete only under a pinned +module baseline. Aliases, supported module-qualified spellings, static call +operator spellings, parameter abbreviations/inline values, positional binding, +parameter sets, and `ScriptBlock[]` binding use the same static catalog. + +An unknown receiver or ambiguous binding is over-approximated as an execution +region with unknown facts. Its body commands remain visible and affected +occurrences and following observable state are incomplete. This can prompt for +a block that runtime ultimately treats as data, but it cannot silently omit a +block that a custom advanced function executes. If the interior cannot be +parsed completely, the whole result is unparseable. Canonical receivers proved +not to execute the bound block remain opaque data and do not require the body +grammar to parse. + New structural nodes preserve their complete source range when it can be mapped exactly. Expanded wrapper content retains the current nullable-span rule rather than inventing offsets into escaped or encoded outer text. @@ -339,7 +422,7 @@ not one entry per predicted runtime iteration. Each occurrence carries its `Clause`, immediate structural role, compositional ancestry suitable for analysis and diagnostics, and an explicit completeness fact. Immediate roles include at least ordinary, pipeline stage, condition, iterator, loop body, -branch, and substitution; ancestry frames retain every outer role, such as a +branch, substitution, and execution region; ancestry frames retain every outer role, such as a pipeline stage nested inside a loop body. The final names are locked with the public API review. @@ -362,13 +445,16 @@ projection exposes a discovered subset. For a fully parseable result, `ParsedCommand.Clauses` contains every authored simple command occurrence in source order, including nested iterator, -condition, branch, body, and substitution commands. It does not invent +condition, branch, body, substitution, and execution-region commands. It does not invent compound operators across structural boundaries. Existing `Clause.Operator` values are retained only for actual authored relationships. Projection order is deterministic. Disjoint executable regions follow authored source order. An enclosed substitution precedes its containing simple command, -and nested substitutions are emitted innermost first. When wrapper decoding +and nested substitutions are emitted innermost first. A command-owned +execution region follows its host occurrence, and sibling regions retain +authored script-block order even when semantic phases execute in another order. +When wrapper decoding makes outer source spans unavailable, containing structural collection order is the tie-breaker. The occurrence and compatibility projections use the same order. @@ -599,8 +685,8 @@ parameter dashes. Stable v0.3 rejects those tokens atomically. The locked v0.2 an alternate dash as positional is unsafe and normalizing it would destroy authored provenance. The structural coordinator also reapplies the existing module-qualified-cmdlet prohibition to every simple-command segment; the -module-qualified `Invoke-Expression` wrapper remains the sole specified -exception. +module-qualified `Invoke-Expression` wrapper and version-pinned +execution-region receiver catalog are the specified exceptions. The PowerShell structural parser clones its compatibility location-attribution context while parsing a loop iterator and body. This prevents a structurally @@ -750,7 +836,9 @@ corpus remains sanitized under the existing PII audit. partial syntax diagnostic-only. - **[Scope grows to every script construct]** -> Treat heredocs, process substitution, background lists, C-style loops, arithmetic, definitions, and - `.ps1` files as separately gated slices. + `.ps1` files as separately gated slices. The PowerShell script-block catalog + is finite and version-pinned; unknown receivers over-approximate one authored + body rather than becoming a general expression evaluator. - **[Candidate combinations become expensive]** -> Apply a small fixed cap and collapse the complete fact to `Unknown` before combinatorial growth. @@ -818,6 +906,12 @@ into the release specifications before production types are added. Process substitution, single-`&` background lists, Bash `case`, PowerShell `switch`, arithmetic/C-style loops, implicit Bash positional-parameter loops, and function/definition bodies remain independently gated. +7. PowerShell direct and command-bound script blocks use the additive + `ExecutionRegionSyntax` contract. Origin, phase, timing, and cardinality are public; + state propagation remains shell-specific and multi-dimensional. The pinned + PowerShell 7 receiver/binding catalog distinguishes proved execution from + proved data. Unknown receivers expose a conservatively executable body with + incomplete facts rather than silently treating it as data. On every unparseable result, `Commands` and the v0.2 `Clauses` projection are empty. `Syntax` may contain a partial diagnostic tree, but it cannot be used as @@ -858,6 +952,7 @@ public enum ShellSyntaxKind Conditional, ConditionalBranch, CommandSubstitution, + ExecutionRegion, } public sealed record ShellBlockSyntax : ShellSyntaxNode @@ -871,6 +966,7 @@ public sealed record SimpleCommandSyntax : ShellSyntaxNode public override ShellSyntaxKind Kind => ShellSyntaxKind.SimpleCommand; public Clause Clause { get; init; } = new(); public IReadOnlyList Substitutions { get; init; } = []; + public IReadOnlyList ExecutionRegions { get; init; } = []; } public sealed record PipelineSyntax : ShellSyntaxNode @@ -961,6 +1057,54 @@ public sealed record CommandSubstitutionSyntax : ShellSyntaxNode public override ShellSyntaxKind Kind => ShellSyntaxKind.CommandSubstitution; public ShellBlockSyntax Body { get; init; } = new(); } + +public sealed record ExecutionRegionSyntax : ShellSyntaxNode +{ + public override ShellSyntaxKind Kind => ShellSyntaxKind.ExecutionRegion; + public ExecutionRegionOrigin Origin { get; init; } + public int? HostClauseElementIndex { get; init; } + public ExecutionRegionPhase Phase { get; init; } + public ExecutionRegionTiming Timing { get; init; } + public ExecutionRegionCardinality Cardinality { get; init; } + public ShellBlockSyntax Body { get; init; } = new(); +} + +public enum ExecutionRegionOrigin +{ + Unknown, + DirectCall, + DotSource, + CommandArgument, +} + +public enum ExecutionRegionPhase +{ + Unknown, + Main, + Initialization, + Begin, + Process, + End, + Filter, + Action, + Completion, +} + +public enum ExecutionRegionTiming +{ + Unknown, + Synchronous, + Concurrent, + Deferred, +} + +public enum ExecutionRegionCardinality +{ + Unknown, + Once, + OncePerInputObject, + ZeroOrMore, +} ``` `ForEachSyntax` shares only proved execution structure. `Iterable.Raw` preserves @@ -982,6 +1126,24 @@ library-owned sealed node implements it internally. Later library versions may add derived records, so authorization code still needs a default fail-closed type-switch arm. +`ExecutionRegionSyntax` is the structural relationship between an authored +body and its activation. `HostClauseElementIndex` is null for direct `& {}` or +`. {}` statements and is the non-negative index into the owning +`SimpleCommandSyntax.Clause.Elements` for a proved command argument binding. +The node's source range covers the direct invocation or the bound script-block +token; `Body` covers the recursively parsed interior. Attached regions are +stored in authored source order. `Phase` lets the shell-specific analyzer apply +Begin/Process/End and initialization/main schedules without rewriting the tree. + +`Timing=Synchronous` means the body completes as part of the containing +invocation, `Concurrent` means instances or the containing continuation may +overlap, and `Deferred` means registration and later trigger are distinct. +`Cardinality=Once` means one activation per proved host invocation, +`OncePerInputObject` is the pipeline callback relationship, and `ZeroOrMore` +is an externally triggered callback. These facts do not specify variable, +location, command-resolution, runspace, or process propagation. Consumers use +occurrence analysis for those facts and fail closed on unknown enum values. + ### Command occurrence and bounded values ```csharp @@ -1006,6 +1168,7 @@ public enum CommandOccurrenceRole LoopBody, Branch, Substitution, + ExecutionRegion, } public sealed record CommandAncestryFrame @@ -1029,6 +1192,7 @@ public enum CommandAncestryRegion Condition, Branch, Substitution, + ExecutionRegion, } public sealed record EffectiveArgument @@ -1442,11 +1606,12 @@ finite domain is limited to literal scalar and array elements whose PowerShell conversion and argument boundaries are completely specified. The current PowerShell lexer emits a balanced `{ ... }` as one `ScriptBlock` -token. The first slice can preserve that behavior for ordinary command -arguments while recursively tokenizing the interior only after the structural -parser has proved that the token is the body of a recognized statement. The -recursive call carries the body's absolute source offset so direct-source -child spans still index `ParsedCommand.Source`. +token. The structural parser recursively tokenizes the interior after it proves +that the token is a statement body, a direct call/dot-source body, a script +block bound to a cataloged execution-bearing parameter, or a conservative +unknown-receiver execution region. A cataloged non-executing data argument +remains opaque. The recursive call carries the body's absolute source offset so +direct-source child spans still index `ParsedCommand.Source`. ### Candidate PowerShell recursive-descent flow @@ -1462,6 +1627,12 @@ internal sealed class PwshStructuralParser return ParseForeach(); } + if (_tokens.AtDirectScriptBlockInvocation("&") || + _tokens.AtDirectScriptBlockInvocation(".")) + { + return ParseDirectExecutionRegion(); + } + return ParseExistingPipeline(); } @@ -1486,11 +1657,12 @@ internal sealed class PwshStructuralParser ``` `ParseExistingPipeline` adapts the current `SplitIntoSegments` and -`BuildSegment` logic. A `ScriptBlock` token remains an opaque `DynamicSkip` -argument everywhere the enclosing grammar does not explicitly own that block -as a statement body. This avoids accidentally executing or authorizing the -contents of `ForEach-Object { ... }`, arbitrary script-block arguments, or a -dynamic call operator. +`BuildSegment` logic. After leaf binding it classifies each `ScriptBlock` +argument against the pinned execution catalog. Proved data remains an opaque +`DynamicSkip`; proved executable binding creates a typed region; unknown +receiver/binding creates an unknown incomplete region. A dynamic call operator +whose block value is not authored inline remains incomplete because no body is +available. `condition_pipeline` is limited to a pipeline that the existing command parser can delimit completely. Pure literal and comparison expressions may be @@ -1504,14 +1676,16 @@ complete command discovery makes the whole result unparseable. | Completely delimited `$()` in a supported word, call-operator dynamic identity, redirect, foreach expression, double-quoted string, or expandable here-string | Inner commands visible; produced value `Unknown`; current-scope state propagates | | Standalone `$()` expression statement | Inner commands visible; no outer invocation is invented | | `& $(...)` dynamic invocation | Inner commands visible, followed by one incomplete dynamic outer occurrence | -| `& { ... }` script-block invocation | Whole result unparseable until body, scope, and state propagation are modeled | +| `& { ... }` and `. { ... }` direct script-block invocation | Typed synchronous region; body commands visible; no synthetic host occurrence | | Single-quoted, literal-here-string, or backtick-escaped `$()` text | Literal/opaque data; no invented substitution occurrence | | Execution-bearing `@()` / `@{}` outside a completely modeled foreach literal expression | Whole result unparseable until complete command discovery is modeled | | `foreach ($name in expression) { ... }` for literal scalar, literal array, or fully delimited pipeline iterables | Supported | | `while (condition_pipeline) { ... }` | Supported | | `if` / `elseif` / `else` with fully delimited condition pipelines | Supported | | Pipeline-produced iterator objects | Iterator commands visible; produced values `Unknown` | -| `ForEach-Object` / `foreach` alias script blocks and ordinary script-block arguments | Existing opaque argument; no invented child execution | +| Cataloged executing script-block arguments | Typed origin/phase/timing/cardinality region; host and body commands visible | +| Cataloged non-executing script-block data | Existing opaque argument; no invented child execution | +| Unknown receiver or ambiguous script-block binding | Unknown region; body visible; affected state and occurrences incomplete | | `do`, `switch`, functions, definitions, class/type bodies, or execution-bearing expressions outside the locked subset | Deferred; whole result unparseable when execution may be hidden | PowerShell `$()` is a value-producing subexpression, not an invocation. @@ -1522,8 +1696,9 @@ subexpression followed by command-style arguments is unparseable. Bash command word formation deliberately differs; a substitution in Bash command-name position makes the whole result unparseable after diagnostic discovery rather than changing the PowerShell-specific `VerbChain.IsDynamic` contract. -Call-operator script-block invocation remains unparseable; an ordinary -script-block argument remains non-executing opaque data. +Direct call-operator and dot-source script blocks lower to execution regions +without synthetic host occurrences. Command-owned blocks use the pinned +receiver/binding catalog; only proved data remains non-executing opaque data. ### Candidate internal nodes and lowering pipeline @@ -1610,6 +1785,9 @@ lattice. Executable-aware interpretation still occurs only in the consumer. | PowerShell `foreach` at statement position followed by `(` | `PwshForEachNode` | Iterator and body occurrences exposed | | PowerShell `foreach` in a pipeline command slot | Existing pipeline/simple-command path | Alias or command semantics preserved | | PowerShell statement body `ScriptBlock` | Interior recursively parsed with adjusted spans | Every body command exposed | -| PowerShell script block used as an ordinary argument | Existing opaque argument | `DynamicSkip`; contents are not invented as executed commands | +| Direct `& {}` / `. {}` script block | Standalone execution-region statement | Body commands exposed; no synthetic operator occurrence | +| PowerShell script block bound to a cataloged executing parameter | Host simple command plus attached execution region | Host and every body command exposed | +| PowerShell script block bound to a cataloged data parameter | Existing opaque argument | `DynamicSkip`; contents are not invented as executed commands | +| Unknown receiver or ambiguous script-block binding | Host plus unknown execution region | Body visible; affected occurrences/state incomplete | | Candidate cap or state-join overflow | Structure remains parseable | Affected effective fact becomes `Unknown` | | Any executable region is skipped or cannot be delimited | Partial diagnostic tree allowed | `IsUnparseable=true`; `Commands` and `Clauses` empty | diff --git a/openspec/changes/v0-3-structured-shell-analysis/proposal.md b/openspec/changes/v0-3-structured-shell-analysis/proposal.md index da4cbc7..e224357 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/proposal.md +++ b/openspec/changes/v0-3-structured-shell-analysis/proposal.md @@ -18,7 +18,14 @@ fail-closed behavior for incomplete analysis. contract where their semantics actually coincide. - Add a library-owned command-occurrence projection containing every command that may execute, including condition, iterator, branch, loop-body, wrapped, - and substitution commands. + substitution, and PowerShell script-block execution-region commands. +- Correct the PowerShell script-block boundary: represent direct invocation, + current-runspace callbacks, child-runspace/process jobs, module + initialization, and deferred actions as typed execution regions while + retaining proved non-executing script-block data as opaque values. Origin, + phase, timing, and cardinality are public structural facts; variable, + location, command-resolution, runspace, and process propagation remain + independent shell-specific analysis. - Add conservative value and shell-state analysis that distinguishes exact, finite, bounded-symbolic, and unknown facts without executing commands or enumerating the filesystem. @@ -82,6 +89,12 @@ PowerShell specifications, and `docs/CONSUMER_GUIDE.md`. Adding properties to public records also changes generated equality, hashing, `ToString()`, and default serialization and therefore requires explicit migration notes. +This accepted scope supersedes the earlier assumption that every ordinary +PowerShell script-block argument is non-executing. Canonical receivers proved +to treat a block as data remain opaque; known execution-bearing bindings are +typed, and unknown receivers conservatively expose the body with incomplete +facts rather than hiding it. + The implementation also corrects a v0.2 security defect at an internal boundary: decoded token text currently loses lexical provenance and consumer/binding context. The correction fixes oracle-proved false exact, 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 8c1b246..b530503 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 @@ -335,7 +335,7 @@ becomes unknown rather than being truncated. The parser SHALL support at most 16 nested executable containers and at most 5 decoded command-string wrapper recursions. Structural depth starts at zero for the root and increments once when entering a foreach loop, condition loop, -conditional, group, or command substitution. Blocks, conditional-branch +conditional, group, command substitution, or execution region. Blocks, conditional-branch records, command lists, pipelines, and simple-command leaves do not increment the depth independently. These bounds SHALL NOT be caller-configurable. Exceeding either bound SHALL make the whole result unparseable rather than @@ -614,6 +614,94 @@ partition merely to publish exact continuation facts. - **THEN** later commands inside the subexpression and in the containing outer continuation have unknown working-directory facts - **THEN** no prior exact cwd is selected as a fallback +### Requirement: PowerShell execution regions use shell-specific state flow +The PowerShell analyzer SHALL interpret execution-region origin, phase, timing, +and cardinality together with the proved host command, parameter set, and current +abstract state. It SHALL NOT infer variable, location, command-resolution, +runspace, or process propagation from one public scope flag. + +The stable v0.3 built-in catalog SHALL cover direct call and dot-source script +blocks; `ForEach-Object` Begin, Process, End, RemainingScripts, and Parallel +binding; `Where-Object -FilterScript`; in-process and remote +`Invoke-Command -ScriptBlock`; `Measure-Command -Expression`; +`Trace-Command -Expression`; `Start-Job` ScriptBlock and +InitializationScript; `New-Module -ScriptBlock`; `Set-PSBreakpoint -Action`; +`Register-ObjectEvent -Action`; `Register-EngineEvent -Action`; and +`Register-ArgumentCompleter -ScriptBlock`. The optional inbox +`Microsoft.PowerShell.ThreadJob` command MAY be complete only when the caller's +pinned module baseline proves its canonical identity; otherwise it follows the +unknown-receiver rule. + +Known aliases, supported module-qualified spellings, static call-operator +spellings, PowerShell parameter prefixes and inline values, positional +binding, parameter-set selection, and `ScriptBlock[]` binding SHALL resolve to +the same catalog entry. In particular, multiple `ForEach-Object` script blocks +SHALL be assigned Begin, Process, and End semantics according to PowerShell's +binder rather than assumed to share the authored parameter name or position. +An ambiguous binding SHALL retain every body as an unknown execution region, +make affected analysis incomplete, and conservatively invalidate following +state that may be observed or mutated. + +Direct `& {}` executes once synchronously in a child variable/command scope +while sharing runspace location. Direct `. {}` executes once synchronously in +the current scope. Their `DirectCall` and `DotSource` origins SHALL remain +distinguishable without source-text reparsing. `ForEach-Object` Begin and End +execute once per invocation; +Process and `Where-Object` Filter execute once per input object and share the +current runspace state. `Measure-Command` and `Trace-Command` expressions +execute synchronously in the current scope. In-process `Invoke-Command` +without `-NoNewScope` isolates ordinary assignment while sharing location; +`-NoNewScope` shares supported state. The in-process parameter set does not +support `-AsJob` and is always synchronous/once. Remote/session/SSH/VM/container +targets, multiple targets, and remote `-AsJob` SHALL retain only facts proved +from the complete parameter set. + +#### Scenario: In-process Invoke-Command does not invent AsJob semantics +- **WHEN** PowerShell parses `Invoke-Command -ScriptBlock { Get-Date }` +- **THEN** the region is synchronous and activates once +- **THEN** analysis does not model `-AsJob` as an in-process option + +#### Scenario: Direct invocation origin survives without source text +- **WHEN** a consumer receives direct call and dot-source execution-region nodes +- **THEN** their origins are `DirectCall` and `DotSource` respectively +- **THEN** the consumer can select child-scope or current-scope state flow without reparsing source text + +`Start-Job` executes initialization before its main block in a child process; +`ForEach-Object -Parallel` and a proved `Start-ThreadJob` execute in child +runspaces. Their exit mutation does not flow into the containing continuation. +Breakpoint actions, event actions, and argument completers are deferred and +may execute zero or more times; their trigger-time cwd and mutable state are +Unknown unless independently proved. Deferred commands remain in the +may-execute projection even though registration itself does not execute them. + +#### Scenario: Child scope and shared location are independent +- **WHEN** isolated-mode PowerShell parses `foreach ($x in 'outer') { }; & { Write-Output inner -OutVariable x; Set-Location /tmp }; Write-Output $x; Get-Location` +- **THEN** the following `$x` proof retains `outer` when the child-scope writer succeeds +- **THEN** supported location outcomes include the call region's `/tmp` mutation +- **THEN** the analyzer does not label both facts shared or both isolated + +#### Scenario: Dot source shares variable and location state +- **WHEN** isolated-mode PowerShell parses `foreach ($x in 'outer') { }; . { Write-Output inner -OutVariable x; Set-Location /tmp }; Write-Output $x; Get-Location` +- **THEN** following analysis observes the supported `x` and location transfers + +#### Scenario: ForEach phases use semantic schedule +- **WHEN** PowerShell authors End, Begin, and Process script blocks out of phase order +- **THEN** syntax and occurrence projection retain authored order +- **THEN** abstract-state execution applies Begin, then Process per input, then End +- **THEN** facts are joined back to their authored occurrences + +#### Scenario: Deferred trigger-time state is unknown +- **WHEN** an event action contains `Remove-Item relative.txt` +- **THEN** the action occurrence remains visible +- **THEN** its trigger-time cwd is Unknown without an independent proof +- **THEN** no registration-time relative path approval is synthesized + +#### Scenario: Ambiguous custom receiver fails closed without hiding the body +- **WHEN** PowerShell parses `Invoke-Custom { Remove-Item target.txt }` without a proved receiver contract +- **THEN** both host and body commands remain visible +- **THEN** their execution-region facts and observing continuation are incomplete +- **THEN** an unsupported body interior makes the whole result unparseable + ### Requirement: Unknown analysis remains policy-sensitive An unknown value SHALL identify the occurrence and position it affects so a consumer can determine whether command identity, option parsing, path scope, diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md index 07e1888..cec6f92 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/consumer-compatibility/spec.md @@ -3,7 +3,8 @@ ### Requirement: Existing Clause projection remains conservative `ParsedCommand.Clauses` SHALL remain available in v0.3 and SHALL contain every authored simple command that may execute for a fully parseable result, including -nested condition, iterator, branch, body, and substitution commands. +nested condition, iterator, branch, body, substitution, and execution-region +commands. Existing raw spelling, decoded values, source spans, and unaffected v0.2 leaf classifications SHALL remain compatible. A paired real-shell oracle MAY @@ -24,6 +25,12 @@ signal. - **THEN** the compatibility clauses include the authored `rm` command - **THEN** its authored variable argument remains conservatively dynamic rather than being silently replaced +#### Scenario: Old consumer sees a script-block body command +- **WHEN** PowerShell fully parses `Get-ChildItem | ForEach-Object { Remove-Item $_ }` +- **THEN** compatibility clauses contain the host and the authored `Remove-Item` body command +- **THEN** the host's script-block argument remains conservatively dynamic +- **THEN** no synthetic operator is invented between host and body + #### Scenario: Structural boundaries do not invent operators - **WHEN** clauses are flattened from separate control-flow regions - **THEN** `Clause.Operator` represents only an actual authored operator relationship @@ -89,6 +96,16 @@ recursive syntax traversal to discover executable commands. - **THEN** it may use syntax ancestry for display - **THEN** authorization still uses the complete occurrence collection +#### Scenario: Deferred execution is authorized at registration +- **WHEN** a registration command contains a deferred event, breakpoint, or completion body +- **THEN** the example authorization algorithm evaluates the registration command and every body occurrence +- **THEN** it does not wait for the external trigger or omit the deferred body + +#### Scenario: Execution metadata does not grant approval +- **WHEN** an execution region is synchronous, concurrent, or deferred +- **THEN** timing and cardinality remain explanatory shell facts +- **THEN** the consumer still interprets every authored command occurrence and policy-sensitive value + ### Requirement: Unknown facts fail closed when policy-sensitive The consumer guide SHALL require strict matching, prompt, or deny whenever an unknown fact can affect command identity, option interpretation, path scope, @@ -104,6 +121,11 @@ position. - **WHEN** a consumer encounters a syntax, role, domain, or redirect kind it does not recognize - **THEN** it fails closed for authorization +#### Scenario: Unknown execution-region facts +- **WHEN** a script-block origin, receiver, phase, timing, cardinality, or trigger-time state is unknown +- **THEN** a consumer prompts or denies whenever the uncertainty affects its policy +- **THEN** it does not treat the visible body as proof that following state is unchanged + ### Requirement: Non-path redirect data does not create implicit approval scope The consumer guide SHALL distinguish complete heredoc and here-string data from command occurrences and filesystem redirect targets. A consumer SHALL NOT diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md index 7178e83..c2f19dd 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/executable-command-projection/spec.md @@ -37,11 +37,12 @@ SHALL use `Statement`; pipelines SHALL use `PipelineStage`; groups SHALL use `GroupBody`; foreach nodes SHALL use `Iterator` or `LoopBody`; condition loops SHALL use `Condition` or `LoopBody`; conditionals SHALL use `Branch`; conditional-branch nodes SHALL use `Condition` or `Branch`; and substitutions -SHALL use `Substitution`. Repeated children SHALL use their zero-based authored +SHALL use `Substitution`; execution regions SHALL use `ExecutionRegion`. +Repeated children SHALL use their zero-based authored index, with an `else` child indexed after all conditional branches. Frame source ranges SHALL identify the ancestor. Blocks, command lists, and groups SHALL retain the incoming immediate role; a nearer pipeline, iterator, body, -condition, branch, or substitution relation SHALL replace it. +condition, branch, substitution, or execution-region relation SHALL replace it. #### Scenario: Root and nested block coordinates are deterministic - **WHEN** a root statement contains a loop-body pipeline @@ -118,6 +119,34 @@ when the produced value is unknown. - **WHEN** Bash encounters `diff <(git show HEAD) <(git show HEAD~1)` before process substitution discovery is supported - **THEN** the result is unparseable rather than omitting either `git` command +### Requirement: Script-block execution regions remain visible +Every completely delimited execution-bearing PowerShell script block SHALL +contribute each authored body command exactly once. A direct call or dot-source +region SHALL contribute only its body commands. A command-owned region SHALL +retain the host command occurrence as well as its body commands. An ambiguous +receiver MAY produce incomplete occurrences with unknown region facts, but it +SHALL NOT omit the body or authorize it as inert data. + +#### Scenario: Direct call operator has no synthetic host +- **WHEN** PowerShell parses `& { Remove-Item target.txt }` +- **THEN** `Remove-Item` appears exactly once with immediate role `ExecutionRegion` +- **THEN** no occurrence is created for `&` or the script block itself + +#### Scenario: Pipeline callback retains host and body +- **WHEN** PowerShell parses `Get-ChildItem | ForEach-Object { Remove-Item $_ }` +- **THEN** occurrences contain `Get-ChildItem`, `ForEach-Object`, and `Remove-Item` exactly once +- **THEN** the body occurrence has execution-region ancestry nested beneath the pipeline stage + +#### Scenario: Deferred callback remains a may-execute command +- **WHEN** PowerShell registers a breakpoint, event action, or argument completer with a supported script block +- **THEN** the registration command and every body command appear exactly once +- **THEN** projection does not predict how many future triggers occur + +#### Scenario: Unknown receiver does not hide a script block +- **WHEN** a script-block argument's receiver or binding is not statically proved +- **THEN** the host and every body command remain visible +- **THEN** the affected occurrence facts are incomplete or unknown + ### Requirement: Occurrence completeness is explicit Each occurrence SHALL state whether its command identity, structural ancestry, and parser-owned shell analysis are complete. No incomplete occurrence SHALL @@ -146,7 +175,10 @@ authored source order. An enclosed substitution SHALL precede its containing simple command. Nested substitutions SHALL be emitted innermost first. When decoded wrapper content has no comparable outer spans, the containing structural collection order SHALL be used. `ParsedCommand.Clauses` SHALL use -the same ordering. A substitution ancestry frame SHALL use +the same ordering. A command-owned execution region SHALL follow its host +command and sibling regions SHALL follow authored script-block order; +shell-specific state analysis MAY schedule semantic phases independently. +A substitution ancestry frame SHALL use `Region=Substitution` and the authored zero-based child index in its containing structural collection. For an embedded simple-command value this is `SimpleCommandSyntax.Substitutions`; for a direct iterator substitution this is @@ -182,6 +214,12 @@ the iterator-command collection. - **THEN** the occurrence collection contains its inner commands only - **THEN** an outer command occurrence exists only when the call operator invokes that value +#### Scenario: Host precedes its script-block regions +- **WHEN** PowerShell parses `ForEach-Object -End { Write-Output end } -Begin { Write-Output begin }` +- **THEN** `ForEach-Object` precedes both body commands +- **THEN** the body commands retain authored End-then-Begin projection order +- **THEN** semantic execution order is an analyzer fact rather than a projection reorder + ### Requirement: Command discovery is library-owned Security consumers SHALL be able to enumerate every potentially executable command without recursively matching syntax-node types. diff --git a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md index 0fd1108..0c17cdf 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md +++ b/openspec/changes/v0-3-structured-shell-analysis/specs/structured-shell-syntax/spec.md @@ -9,7 +9,7 @@ The public syntax family SHALL be a closed hierarchy of records derived from `ShellSyntaxNode`. Every node SHALL expose a `ShellSyntaxKind` discriminant and zero SHALL mean `Unknown`. The locked family SHALL include block, simple command, pipeline, command list, group, foreach, condition loop, conditional, -conditional branch, and command substitution nodes. +conditional branch, command substitution, and execution-region nodes. #### Scenario: Existing flat command receives a structural root - **WHEN** either parser parses `git status && dotnet test` @@ -30,7 +30,9 @@ A simple-command syntax node SHALL expose the existing `Clause` facts rather than replacing `VerbChain`, `Arg`, `Redirect`, or `ClauseElement` with a second incompatible leaf model. It SHALL also own an authored-order collection of completely delimited command substitutions evaluated for its words and -redirects, including expanding heredoc bodies. Nested substitutions SHALL +redirects, including expanding heredoc bodies, and an authored-order +collection of execution-bearing regions bound to its arguments. Nested +substitutions and execution regions SHALL remain attached to the nearest containing simple command; they SHALL NOT be promoted to unrelated siblings or stored only in a side table. @@ -49,6 +51,12 @@ promoted to unrelated siblings or stored only in a side table. - **THEN** the inner substitution belongs to the simple command inside the outer substitution - **THEN** the inner substitution is not flattened into the outer command's substitution collection +#### Scenario: Cmdlet-owned execution region preserves its host +- **WHEN** PowerShell parses `Get-ChildItem | ForEach-Object { Remove-Item $_ }` +- **THEN** the `ForEach-Object` simple-command node owns one execution region +- **THEN** the region body contains `Remove-Item` +- **THEN** the authored script-block argument remains on the host `Clause` + ### Requirement: Executable substitution boundaries are accounted for Stable v0.3 SHALL recursively parse every completely delimited Bash `$()` or PowerShell `$()` that can execute while forming a supported simple-command @@ -95,10 +103,78 @@ escaped substitution-looking text SHALL NOT create syntax or occurrences. - **THEN** `Write-Output` is exposed before one incomplete dynamic outer invocation - **THEN** the produced string is not assumed to equal a static command identity -#### Scenario: PowerShell call operator script block remains gated +#### Scenario: PowerShell call operator script block is a direct execution region - **WHEN** PowerShell encounters `& { Remove-Item target.txt }` -- **THEN** the whole result is unparseable until script-block execution semantics are modeled -- **THEN** the body is not treated as an ordinary opaque argument +- **THEN** the root contains a synchronous, once-per-invocation execution region +- **THEN** the body contains `Remove-Item` +- **THEN** no synthetic outer command occurrence is invented for `&` + +### Requirement: Execution-bearing regions are typed independently from scope +An `ExecutionRegionSyntax` SHALL represent a completely delimited authored +body that may execute because of a direct shell invocation operator or a +recognized command argument binding. It SHALL expose an execution origin, +phase, timing, cardinality, exact-or-null source range, optional host +`ClauseElement` coordinate, and body. Origin, phase, timing, and cardinality +SHALL be independent enum facts whose zero values are `Unknown`. + +Origin SHALL have `Unknown`, `DirectCall`, `DotSource`, and `CommandArgument` +values. Direct call and dot-source regions SHALL retain their distinct origins +even when source text is unavailable to a consumer. A command-owned region +SHALL use `CommandArgument`. + +The region SHALL NOT expose one shared/isolated scope flag. PowerShell +variable, working-directory, command-resolution, runspace, and process state +do not share one boundary: for example, `& {}` isolates ordinary variable +assignment while sharing location. Those effects SHALL remain shell-specific +analysis and SHALL be reflected in occurrence facts and following state. + +The containing command's `ExecutionRegions` collection SHALL preserve authored +script-block order. Semantic phase order MAY differ and SHALL be consumed by +the shell-specific analyzer rather than by reordering authored syntax. A direct +`& {}` or `. {}` region SHALL appear as a statement, SHALL use `DirectCall` or +`DotSource` respectively, and SHALL have no host element coordinate. A region +bound to a command argument SHALL be attached to +that `SimpleCommandSyntax` and SHALL identify the exact script-block +`ClauseElement` when the binding is proved. + +#### Scenario: Reordered pipeline phases retain both orders +- **WHEN** PowerShell parses `1 | ForEach-Object -End { Write-Output end } -Begin { Write-Output begin } -Process { Write-Output $_ }` +- **THEN** the execution-region collection retains the authored `End`, `Begin`, `Process` order +- **THEN** each region carries its semantic phase +- **THEN** state analysis applies Begin, Process, End semantics without rewriting the authored tree + +#### Scenario: Dot-sourced block has current-scope effects +- **WHEN** PowerShell parses `. { $x = 'changed'; Set-Location /tmp }` +- **THEN** the body is a synchronous once-per-invocation execution region +- **THEN** supported variable and location changes propagate according to dot-source semantics +- **THEN** the `.` operator does not become a synthetic command occurrence + +#### Scenario: Direct block arguments remain atomic until binding is modeled +- **WHEN** PowerShell encounters `& { Write-Output $args } alpha` or `. { Write-Output $args } alpha` +- **THEN** the whole parse is unparseable +- **THEN** no body command is exposed as authorization evidence from a partial binding model + +#### Scenario: Leading region parameter declaration remains atomic +- **WHEN** an execution region begins with `param(...)` +- **THEN** the whole parse is unparseable until bounded declaration grammar is implemented +- **THEN** a realistic argument completer is not partially authorized from only its post-declaration body + +#### Scenario: Deferred action is still authorization-visible +- **WHEN** PowerShell parses `Register-EngineEvent -SourceIdentifier ready -Action { Remove-Item marker.txt }` +- **THEN** the host command appears before the action body in the occurrence projection +- **THEN** the action region is `Deferred` with `ZeroOrMore` cardinality +- **THEN** `Remove-Item` remains authorization-visible even though registration does not execute it + +#### Scenario: Known non-executing script-block data stays data +- **WHEN** a constrained canonical-command context parses `Write-Output { Remove-Item target.txt }` +- **THEN** the script block remains one opaque compatibility argument +- **THEN** no execution region or `Remove-Item` occurrence is invented + +#### Scenario: Unknown script-block receiver over-approximates execution +- **WHEN** command resolution or script-block parameter binding cannot prove whether a receiver executes its block +- **THEN** the completely parsed block is retained as an execution region with unknown facts +- **THEN** affected occurrences are incomplete +- **THEN** an unsupported block interior makes the whole result unparseable rather than hiding commands ### Requirement: Bash for-in loops preserve header and body structure The Bash parser SHALL represent a supported `for name in words; do body; done` @@ -200,6 +276,6 @@ syntax SHALL be diagnostic evidence only. - **THEN** the whole result is unparseable until concurrency and state boundaries are specified #### Scenario: Ordinary PowerShell script-block argument -- **WHEN** PowerShell parses a script block as an ordinary command argument rather than a recognized statement body +- **WHEN** PowerShell parses a script block for a canonical receiver proved not to execute that argument - **THEN** it remains an opaque dynamic argument -- **THEN** the parser does not invent the block contents as commands that necessarily execute +- **THEN** the parser does not invent the block contents as commands diff --git a/openspec/changes/v0-3-structured-shell-analysis/tasks.md b/openspec/changes/v0-3-structured-shell-analysis/tasks.md index 8e09771..f59f8f2 100644 --- a/openspec/changes/v0-3-structured-shell-analysis/tasks.md +++ b/openspec/changes/v0-3-structured-shell-analysis/tasks.md @@ -10,6 +10,14 @@ - [x] 1.8 Update `PROJECT_CONTEXT.md` and `IMPLEMENTATION_PLAN.md` with the accepted v0.3 scope and delivery slices. - [x] 1.9 Add a paired Bash and PowerShell design corpus that records current behavior, desired structure, command occurrences, bounded values, redirect facts, compatibility projections, and security invariants. - [ ] 1.10 Promote each design case into the executable corpus as its production parser slice lands. +- [x] 1.11 Correct the PowerShell script-block boundary and lock the additive + execution-region node, origin/phase/timing/cardinality facts, authored-versus-semantic + ordering, command projection, and independent shell-state analysis contract + against local PowerShell 7.6.4 oracles. +- [x] 1.12 Expand the PowerShell design corpus with direct call/dot-source, + synchronous callback, binder phase, local/remote invocation, child + process/runspace, initialization, deferred action, proved data, and unknown + receiver cases before production implementation. ## 2. Resolver Provenance Correction and Shared Preparation @@ -44,6 +52,12 @@ - [x] 3.13a Promote the Bash ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, and hidden-execution cases into its executable corpus. - [x] 3.13b Promote the PowerShell ordinary, multiple, nested, redirect, quoted, escaped, stateful, malformed, expression-boundary, and hidden-execution cases into its executable corpus. - [x] 3.13c Promote expanding, literal, tab-stripped, multiple, and malformed Bash heredoc cases with full structural expectations into the executable corpus. +- [x] 3.14 Add `ExecutionRegionSyntax`, its four discriminant enums, + `SimpleCommandSyntax.ExecutionRegions`, and appended occurrence/ancestry enum + members to the public API and snapshot without changing existing enum values. +- [ ] 3.15 Extend the structural projector, compatibility flattener, depth + validation, cloning, and corpus DTOs so direct and command-owned execution + regions emit every body command exactly once in the locked order. ## 4. Explicit Redirect Semantics @@ -167,6 +181,28 @@ pipeline, alias/cmdlet/native, redirect, and adversarial matrices remain in tasks 7.5-7.7. - [ ] 7.5 Cover aliases, cmdlets, native commands, nested loops, pipelines, script blocks, and wrapper boundaries. + - [ ] 7.5a Implement the version-pinned PowerShell 7 script-block receiver and + parameter-binding catalog, including aliases, supported module-qualified + identities, parameter abbreviations/inline values, positional binding, + parameter sets, `ScriptBlock[]`, and ForEach-Object Begin/Process/End + assignment. + - [ ] 7.5b Implement direct `& {}` and `. {}` plus synchronous current-runspace + regions for ForEach-Object, Where-Object, Measure-Command, Trace-Command, + in-process Invoke-Command, and New-Module with shell-specific state flow. + - [ ] 7.5c Implement Start-Job and initialization, ForEach-Object -Parallel, + remote/session Invoke-Command, `-AsJob`, and pinned Start-ThreadJob child + process/runspace boundaries conservatively. + - [ ] 7.5d Implement deferred breakpoint, event, and argument-completion action + regions with trigger-time unknown state and zero-or-more cardinality. + - [ ] 7.5e Keep proved non-executing script-block data opaque; over-approximate + unknown receivers/bindings as unknown incomplete regions; fail atomically + on unsupported interiors or state transfers. + - [ ] 7.5f Pin authored projection order separately from semantic phase order, + exact host element coordinates, nested regions, wrappers, pipelines, loops, + and the 16-container depth boundary. + - [ ] 7.5g Either implement a bounded leading `param(...)` declaration and + direct-block argument-binding grammar or retain explicit atomic-failure + cases for both direct operators and realistic argument completers. - [ ] 7.6 Add adversarial cases for object-valued iterables, mutation, dynamic invocation, splatting, and cap overflow. - [ ] 7.7 Add PowerShell corpus entries, live `pwsh` oracle coverage, and Netclaw integration cases. - Add case-specific `PwshInitialStateMode` support to `PwshCorpusTool` before diff --git a/src/ShellSyntaxTree/CommandOccurrence.cs b/src/ShellSyntaxTree/CommandOccurrence.cs index ba0f66b..9853123 100644 --- a/src/ShellSyntaxTree/CommandOccurrence.cs +++ b/src/ShellSyntaxTree/CommandOccurrence.cs @@ -58,6 +58,8 @@ public enum CommandOccurrenceRole Branch, /// A substitution command. Substitution, + /// A command inside an execution-bearing region. + ExecutionRegion, } /// One compositional structural ancestor of a command occurrence. @@ -102,6 +104,8 @@ public enum CommandAncestryRegion Branch, /// A command substitution. Substitution, + /// An execution-bearing region. + ExecutionRegion, } /// A bounded effective value at one authored clause-element coordinate. diff --git a/src/ShellSyntaxTree/ShellSyntaxNode.cs b/src/ShellSyntaxTree/ShellSyntaxNode.cs index 947877d..6007f6c 100644 --- a/src/ShellSyntaxTree/ShellSyntaxNode.cs +++ b/src/ShellSyntaxTree/ShellSyntaxNode.cs @@ -54,6 +54,8 @@ public enum ShellSyntaxKind ConditionalBranch, /// A command substitution. CommandSubstitution, + /// An authored body activated by shell or command semantics. + ExecutionRegion, } /// An ordered block of authored statements. @@ -86,6 +88,13 @@ public sealed record SimpleCommandSyntax : ShellSyntaxNode /// public IReadOnlyList Substitutions { get; init; } = Array.Empty(); + + /// + /// Gets execution-bearing regions bound to this command's arguments, in + /// authored order. + /// + public IReadOnlyList ExecutionRegions { get; init; } = + Array.Empty(); } /// An ordered pipeline. @@ -265,3 +274,95 @@ public sealed record CommandSubstitutionSyntax : ShellSyntaxNode /// Gets the commands inside the substitution. public ShellBlockSyntax Body { get; init; } = new(); } + +/// An authored body whose activation is described by shell semantics. +public sealed record ExecutionRegionSyntax : ShellSyntaxNode +{ + private protected override bool IsLibraryOwnedNode => true; + + /// + public override ShellSyntaxKind Kind => ShellSyntaxKind.ExecutionRegion; + + /// Gets the syntax or binding that introduces the region. + public ExecutionRegionOrigin Origin { get; init; } + + /// + /// Gets the index of the bound script-block token in the owning command's + /// , or null for a direct shell invocation. + /// + public int? HostClauseElementIndex { get; init; } + + /// Gets the semantic activation phase. + public ExecutionRegionPhase Phase { get; init; } + + /// Gets the relationship between registration and execution time. + public ExecutionRegionTiming Timing { get; init; } + + /// Gets how activations relate to one authored region. + public ExecutionRegionCardinality Cardinality { get; init; } + + /// Gets the recursively parsed region body. + public ShellBlockSyntax Body { get; init; } = new(); +} + +/// Identifies how an authored execution region is introduced. +public enum ExecutionRegionOrigin +{ + /// The introducing syntax or binding is unknown. + Unknown, + /// A direct PowerShell call-operator script block: & { ... }. + DirectCall, + /// A direct PowerShell dot-source script block: . { ... }. + DotSource, + /// A script block bound to an argument of a host command. + CommandArgument, +} + +/// Identifies an execution region's semantic phase. +public enum ExecutionRegionPhase +{ + /// The phase is unknown. + Unknown, + /// The primary body of an invocation. + Main, + /// An initialization body. + Initialization, + /// A pipeline begin body. + Begin, + /// A pipeline process body. + Process, + /// A pipeline end body. + End, + /// A per-input filter body. + Filter, + /// An externally triggered action body. + Action, + /// An argument-completion body. + Completion, +} + +/// Identifies when an execution region may run. +public enum ExecutionRegionTiming +{ + /// The timing is unknown. + Unknown, + /// The body completes as part of the containing invocation. + Synchronous, + /// Instances or the containing continuation may overlap. + Concurrent, + /// Registration and a later trigger are distinct. + Deferred, +} + +/// Identifies how often one authored execution region may activate. +public enum ExecutionRegionCardinality +{ + /// The activation cardinality is unknown. + Unknown, + /// The body activates once per proved host invocation. + Once, + /// The body activates once per input object. + OncePerInputObject, + /// The body may activate zero or more times. + ZeroOrMore, +} diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs index b7c824a..3238f1e 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/V03DesignCorpusTests.cs @@ -62,6 +62,27 @@ public void Design_corpus_is_well_formed_and_balanced_across_shells() Assert.Contains(file.Cases, designCase => designCase.PowerShellInitialStateMode == PwshInitialStateMode.IsolatedNonInteractiveNoProfile); + + var regions = file.Cases + .SelectMany(designCase => designCase.Desired.Syntax) + .Where(node => node.Kind == DesignSyntaxKind.ExecutionRegion) + .ToArray(); + Assert.NotEmpty(regions); + Assert.All( + Enum.GetValues() + .Where(origin => origin != DesignExecutionRegionOrigin.Unknown), + origin => Assert.Contains(regions, node => node.ExecutionOrigin == origin)); + Assert.All( + Enum.GetValues(), + phase => Assert.Contains(regions, node => node.ExecutionPhase == phase)); + Assert.All( + Enum.GetValues(), + timing => Assert.Contains(regions, node => node.ExecutionTiming == timing)); + Assert.All( + Enum.GetValues(), + cardinality => Assert.Contains( + regions, + node => node.ExecutionCardinality == cardinality)); } else { @@ -149,6 +170,33 @@ private static void ValidateDesiredShape(DesignShell shell, V03DesignCase design Assert.Equal(DesignSyntaxKind.SimpleCommand, node.Kind); Assert.InRange(node.CommandIndex.Value, 0, desired.Commands.Count - 1); } + + if (node.Kind == DesignSyntaxKind.ExecutionRegion) + { + Assert.NotNull(node.ExecutionOrigin); + Assert.NotEqual(DesignExecutionRegionOrigin.Unknown, node.ExecutionOrigin); + Assert.NotNull(node.ExecutionPhase); + Assert.NotNull(node.ExecutionTiming); + Assert.NotNull(node.ExecutionCardinality); + if (node.Parent is not null && + nodes[node.Parent].Kind == DesignSyntaxKind.SimpleCommand) + { + Assert.NotNull(node.HostClauseElementIndex); + Assert.True(node.HostClauseElementIndex >= 0); + } + else + { + Assert.Null(node.HostClauseElementIndex); + } + } + else + { + Assert.Null(node.HostClauseElementIndex); + Assert.Null(node.ExecutionOrigin); + Assert.Null(node.ExecutionPhase); + Assert.Null(node.ExecutionTiming); + Assert.Null(node.ExecutionCardinality); + } } if (desired.IsUnparseable) @@ -528,6 +576,16 @@ public sealed record DesignSyntaxExpectation public string? Binding { get; init; } public int? CommandIndex { get; init; } + + public int? HostClauseElementIndex { get; init; } + + public DesignExecutionRegionOrigin? ExecutionOrigin { get; init; } + + public DesignExecutionRegionPhase? ExecutionPhase { get; init; } + + public DesignExecutionRegionTiming? ExecutionTiming { get; init; } + + public DesignExecutionRegionCardinality? ExecutionCardinality { get; init; } } public sealed record DesignCommandExpectation @@ -696,6 +754,7 @@ public enum DesignSyntaxKind ConditionLoop, Conditional, CommandSubstitution, + ExecutionRegion, Group, SimpleCommand, OpaqueArgument, @@ -713,6 +772,7 @@ public enum DesignSyntaxSlot Else, Stage, Substitution, + ExecutionRegion, Argument, } @@ -725,6 +785,44 @@ public enum DesignCommandRole LoopBody, Branch, Substitution, + ExecutionRegion, +} + +public enum DesignExecutionRegionPhase +{ + Unknown, + Main, + Initialization, + Begin, + Process, + End, + Filter, + Action, + Completion, +} + +public enum DesignExecutionRegionOrigin +{ + Unknown, + DirectCall, + DotSource, + CommandArgument, +} + +public enum DesignExecutionRegionTiming +{ + Unknown, + Synchronous, + Concurrent, + Deferred, +} + +public enum DesignExecutionRegionCardinality +{ + Unknown, + Once, + OncePerInputObject, + ZeroOrMore, } public enum DesignValueKind diff --git a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json index a19d410..cab04b4 100644 --- a/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json +++ b/tests/ShellSyntaxTree.Tests/DesignCorpus/v0.3/powershell.json @@ -126,21 +126,23 @@ } }, { - "id": "pwsh-call-operator-script-block-gated", - "concern": "Invoked script block is not treated as an ordinary opaque argument", + "id": "pwsh-call-operator-script-block-region", + "concern": "Direct call-operator script block exposes its body without a synthetic host command", "input": "& { Remove-Item target.txt }", - "current": { "isUnparseable": false }, + "current": { "isUnparseable": true }, "desired": { - "isUnparseable": true, "syntax": [ { "id": "root", "kind": "Block", "slot": "Root" }, - { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + { "id": "region", "kind": "ExecutionRegion", "parent": "root", "slot": "Statement", "executionOrigin": "DirectCall", "executionPhase": "Main", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "region", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } ], - "commands": [], - "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, - "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] - }, - "compatibilityProjectionLanded": true + "commands": [ + { "authoredVerb": "Remove-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "region", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["Remove-Item"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "NoSyntheticOperator"] + } }, { "id": "pwsh-standalone-subexpression-is-not-invocation", @@ -443,7 +445,9 @@ { "id": "pipe", "kind": "Pipeline", "parent": "root", "slot": "Statement" }, { "id": "get", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, { "id": "foreachAlias", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 }, - { "id": "scriptBlock", "kind": "OpaqueArgument", "parent": "foreachAlias", "slot": "Argument" } + { "id": "process", "kind": "ExecutionRegion", "parent": "foreachAlias", "slot": "ExecutionRegion", "hostClauseElementIndex": 1, "executionOrigin": "CommandArgument", "executionPhase": "Process", "executionTiming": "Synchronous", "executionCardinality": "OncePerInputObject" }, + { "id": "processBody", "kind": "Block", "parent": "process", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "processBody", "slot": "Statement", "commandIndex": 2 } ], "commands": [ { "authoredVerb": "Get-ChildItem", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, @@ -456,12 +460,21 @@ "effectiveValues": [ { "sourceElement": "{ Remove-Item $_ }", "kind": "Unknown", "isPolicySensitive": true } ] + }, + { + "authoredVerb": "Remove-Item", + "immediateRole": "ExecutionRegion", + "ancestry": ["root", "pipe", "process", "processBody"], + "isComplete": true, + "effectiveValues": [ + { "sourceElement": "$_", "kind": "Unknown", "isPolicySensitive": true } + ] } ], - "compatibility": { "verbs": ["Get-ChildItem", "foreach"], "preservesAuthoredDynamicValues": true }, - "securityInvariants": ["ContextualKeywordNotControlFlow", "OpaqueDataNotExecuted", "UnknownPolicyValueFailsClosed"] + "compatibility": { "verbs": ["Get-ChildItem", "foreach", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["ContextualKeywordNotControlFlow", "AllCommandsVisible", "UnknownPolicyValueFailsClosed"] }, - "notes": "ShellSyntaxTree does not assume that a ForEach-Object script-block argument executes as an independently authorized shell command." + "notes": "The foreach alias remains a pipeline command, but its known Process script block is a typed execution region." }, { "id": "pwsh-malformed-foreach-missing-body", @@ -2152,6 +2165,347 @@ "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "ShellSpecificOptionSemantics"] } + }, + { + "id": "pwsh-dot-source-script-block-region", + "concern": "Direct dot-source script block exposes a current-scope body without a synthetic host command", + "input": ". { Get-Location }", + "current": { "isUnparseable": true }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "region", "kind": "ExecutionRegion", "parent": "root", "slot": "Statement", "executionOrigin": "DotSource", "executionPhase": "Main", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "region", "slot": "Body" }, + { "id": "getLocation", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 0 } + ], + "commands": [ + { "authoredVerb": "Get-Location", "immediateRole": "ExecutionRegion", "ancestry": ["root", "region", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["Get-Location"], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["AllCommandsVisible", "NoSyntheticOperator"] + } + }, + { + "id": "pwsh-script-block-proved-data", + "concern": "Canonical Write-Output consumes a script block as data rather than executing its contents", + "input": "Write-Output { Remove-Item target.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "write", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "data", "kind": "OpaqueArgument", "parent": "write", "slot": "Argument" } + ], + "commands": [ + { "authoredVerb": "Write-Output", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true } + ], + "compatibility": { "verbs": ["Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "OpaqueDataNotExecuted"] + } + }, + { + "id": "pwsh-script-block-unknown-receiver", + "concern": "Unknown receiver over-approximates a script-block argument as executable", + "input": "Invoke-Custom { Remove-Item target.txt }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "region", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 1, "executionOrigin": "CommandArgument", "executionPhase": "Unknown", "executionTiming": "Unknown", "executionCardinality": "Unknown" }, + { "id": "body", "kind": "Block", "parent": "region", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Invoke-Custom", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": false }, + { "authoredVerb": "Remove-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "region", "body"], "isComplete": false } + ], + "compatibility": { "verbs": ["Invoke-Custom", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + } + }, + { + "id": "pwsh-where-object-filter-region", + "concern": "Where-Object FilterScript executes once per pipeline input object", + "input": "Get-ChildItem | Where-Object -FilterScript { Test-Path $_ }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "pipe", "kind": "Pipeline", "parent": "root", "slot": "Statement" }, + { "id": "get", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, + { "id": "host", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 }, + { "id": "filter", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "Filter", "executionTiming": "Synchronous", "executionCardinality": "OncePerInputObject" }, + { "id": "body", "kind": "Block", "parent": "filter", "slot": "Body" }, + { "id": "test", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 2 } + ], + "commands": [ + { "authoredVerb": "Get-ChildItem", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, + { "authoredVerb": "Where-Object", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, + { "authoredVerb": "Test-Path", "immediateRole": "ExecutionRegion", "ancestry": ["root", "pipe", "filter", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["Get-ChildItem", "Where-Object", "Test-Path"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-foreach-object-authored-versus-phase-order", + "concern": "ForEach-Object retains authored script-block order while exposing semantic phases", + "input": "ForEach-Object -End { Write-Output end } -Begin { Write-Output begin } -Process { Write-Output process }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "end", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "End", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "endBody", "kind": "Block", "parent": "end", "slot": "Body" }, + { "id": "endWrite", "kind": "SimpleCommand", "parent": "endBody", "slot": "Statement", "commandIndex": 1 }, + { "id": "begin", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 4, "executionOrigin": "CommandArgument", "executionPhase": "Begin", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "beginBody", "kind": "Block", "parent": "begin", "slot": "Body" }, + { "id": "beginWrite", "kind": "SimpleCommand", "parent": "beginBody", "slot": "Statement", "commandIndex": 2 }, + { "id": "process", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 6, "executionOrigin": "CommandArgument", "executionPhase": "Process", "executionTiming": "Synchronous", "executionCardinality": "OncePerInputObject" }, + { "id": "processBody", "kind": "Block", "parent": "process", "slot": "Body" }, + { "id": "processWrite", "kind": "SimpleCommand", "parent": "processBody", "slot": "Statement", "commandIndex": 3 } + ], + "commands": [ + { "authoredVerb": "ForEach-Object", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "end", "endBody"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "begin", "beginBody"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "process", "processBody"], "isComplete": true } + ], + "compatibility": { "verbs": ["ForEach-Object", "Write-Output", "Write-Output", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StateJoinConservative", "NoSyntheticOperator"] + } + }, + { + "id": "pwsh-invoke-command-local-region", + "concern": "Local Invoke-Command exposes a synchronous main execution region", + "input": "Invoke-Command -ScriptBlock { Get-Item child.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "main", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "Main", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "main", "slot": "Body" }, + { "id": "get", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Invoke-Command", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Get-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "main", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["Invoke-Command", "Get-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StateJoinConservative"] + } + }, + { + "id": "pwsh-invoke-command-remote-region", + "concern": "Remote Invoke-Command exposes its body while withholding unproved remote state", + "input": "Invoke-Command -ComputerName server -ScriptBlock { Get-Item child.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "main", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 4, "executionOrigin": "CommandArgument", "executionPhase": "Main", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "main", "slot": "Body" }, + { "id": "get", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Invoke-Command", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Get-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "main", "body"], "isComplete": false } + ], + "compatibility": { "verbs": ["Invoke-Command", "Get-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + } + }, + { + "id": "pwsh-start-job-initialization-and-main", + "concern": "Start-Job exposes authored main and initialization blocks while analysis schedules initialization first", + "input": "Start-Job -ScriptBlock { Write-Output main } -InitializationScript { Write-Output init }", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "main", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "Main", "executionTiming": "Concurrent", "executionCardinality": "Once" }, + { "id": "mainBody", "kind": "Block", "parent": "main", "slot": "Body" }, + { "id": "mainWrite", "kind": "SimpleCommand", "parent": "mainBody", "slot": "Statement", "commandIndex": 1 }, + { "id": "init", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 4, "executionOrigin": "CommandArgument", "executionPhase": "Initialization", "executionTiming": "Concurrent", "executionCardinality": "Once" }, + { "id": "initBody", "kind": "Block", "parent": "init", "slot": "Body" }, + { "id": "initWrite", "kind": "SimpleCommand", "parent": "initBody", "slot": "Statement", "commandIndex": 2 } + ], + "commands": [ + { "authoredVerb": "Start-Job", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "main", "mainBody"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "init", "initBody"], "isComplete": true } + ], + "compatibility": { "verbs": ["Start-Job", "Write-Output", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StateJoinConservative", "NoSyntheticOperator"] + } + }, + { + "id": "pwsh-foreach-object-parallel-region", + "concern": "ForEach-Object Parallel exposes a concurrent child-runspace process region", + "input": "Get-ChildItem | ForEach-Object -Parallel { Remove-Item $_ }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "pipe", "kind": "Pipeline", "parent": "root", "slot": "Statement" }, + { "id": "get", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 0 }, + { "id": "host", "kind": "SimpleCommand", "parent": "pipe", "slot": "Stage", "commandIndex": 1 }, + { "id": "parallel", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "Process", "executionTiming": "Concurrent", "executionCardinality": "OncePerInputObject" }, + { "id": "body", "kind": "Block", "parent": "parallel", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 2 } + ], + "commands": [ + { "authoredVerb": "Get-ChildItem", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, + { "authoredVerb": "ForEach-Object", "immediateRole": "PipelineStage", "ancestry": ["root", "pipe"], "isComplete": true }, + { "authoredVerb": "Remove-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "pipe", "parallel", "body"], "isComplete": false } + ], + "compatibility": { "verbs": ["Get-ChildItem", "ForEach-Object", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed", "StateJoinConservative"] + } + }, + { + "id": "pwsh-event-action-deferred-region", + "concern": "Event action is visible at registration but executes only on a later trigger", + "input": "Register-EngineEvent -SourceIdentifier ready -Action { Remove-Item marker.txt }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "action", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 4, "executionOrigin": "CommandArgument", "executionPhase": "Action", "executionTiming": "Deferred", "executionCardinality": "ZeroOrMore" }, + { "id": "body", "kind": "Block", "parent": "action", "slot": "Body" }, + { "id": "remove", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Register-EngineEvent", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Remove-Item", "immediateRole": "ExecutionRegion", "ancestry": ["root", "action", "body"], "isComplete": false } + ], + "compatibility": { "verbs": ["Register-EngineEvent", "Remove-Item"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-argument-completer-deferred-region", + "concern": "Argument completer body is deferred until completion and remains authorization-visible", + "input": "Register-ArgumentCompleter -CommandName tool -ScriptBlock { Write-Output candidate }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "completion", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 4, "executionOrigin": "CommandArgument", "executionPhase": "Completion", "executionTiming": "Deferred", "executionCardinality": "ZeroOrMore" }, + { "id": "body", "kind": "Block", "parent": "completion", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "Register-ArgumentCompleter", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "completion", "body"], "isComplete": false } + ], + "compatibility": { "verbs": ["Register-ArgumentCompleter", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-new-module-initialization-region", + "concern": "New-Module script block is immediate module initialization rather than opaque data", + "input": "New-Module -ScriptBlock { Write-Output initialized }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "host", "kind": "SimpleCommand", "parent": "root", "slot": "Statement", "commandIndex": 0 }, + { "id": "init", "kind": "ExecutionRegion", "parent": "host", "slot": "ExecutionRegion", "hostClauseElementIndex": 2, "executionOrigin": "CommandArgument", "executionPhase": "Initialization", "executionTiming": "Synchronous", "executionCardinality": "Once" }, + { "id": "body", "kind": "Block", "parent": "init", "slot": "Body" }, + { "id": "write", "kind": "SimpleCommand", "parent": "body", "slot": "Statement", "commandIndex": 1 } + ], + "commands": [ + { "authoredVerb": "New-Module", "immediateRole": "Ordinary", "ancestry": ["root"], "isComplete": true }, + { "authoredVerb": "Write-Output", "immediateRole": "ExecutionRegion", "ancestry": ["root", "init", "body"], "isComplete": true } + ], + "compatibility": { "verbs": ["New-Module", "Write-Output"], "preservesAuthoredDynamicValues": true }, + "securityInvariants": ["AllCommandsVisible", "StateJoinConservative"] + } + }, + { + "id": "pwsh-invoke-command-local-asjob-invalid-parameter-set", + "concern": "In-process Invoke-Command never acquires invented concurrent semantics from an invalid AsJob combination", + "input": "Invoke-Command -ScriptBlock { Get-Date } -AsJob", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed", "ShellSpecificOptionSemantics"] + }, + "notes": "PowerShell 7.6.4 Get-Command metadata places -NoNewScope in the InProcess parameter set and -AsJob only in non-local parameter sets." + }, + { + "id": "pwsh-direct-call-trailing-arguments-fail-closed", + "concern": "Direct call-operator block arguments remain atomic until parameter binding is modeled", + "input": "& { Write-Output $args } alpha", + "current": { "isUnparseable": true }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-dot-source-trailing-arguments-fail-closed", + "concern": "Dot-source block arguments remain atomic until parameter binding is modeled", + "input": ". { Write-Output $args } alpha", + "current": { "isUnparseable": true }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } + }, + { + "id": "pwsh-execution-region-param-header-fail-closed", + "concern": "A leading param declaration keeps the complete argument-completer region atomic until declaration grammar lands", + "input": "Register-ArgumentCompleter -CommandName tool -ScriptBlock { param($commandName) Write-Output $commandName }", + "powerShellInitialStateMode": "IsolatedNonInteractiveNoProfile", + "current": { "isUnparseable": false }, + "desired": { + "isUnparseable": true, + "syntax": [ + { "id": "root", "kind": "Block", "slot": "Root" }, + { "id": "unsupported", "kind": "Unsupported", "parent": "root", "slot": "Statement" } + ], + "commands": [], + "compatibility": { "verbs": [], "preservesAuthoredDynamicValues": false }, + "securityInvariants": ["PartialTreeDiagnosticOnly", "UnknownPolicyValueFailsClosed"] + } } ] } diff --git a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs index 82ab8eb..adbee47 100644 --- a/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/PublicApiSnapshotTests.cs @@ -533,6 +533,11 @@ public void Public_namespace_contains_only_expected_types() nameof(ConditionLoopKind), nameof(ConditionLoopSyntax), nameof(EffectiveArgument), + nameof(ExecutionRegionCardinality), + nameof(ExecutionRegionOrigin), + nameof(ExecutionRegionPhase), + nameof(ExecutionRegionSyntax), + nameof(ExecutionRegionTiming), nameof(ForEachSyntax), nameof(GroupSyntax), nameof(HereDocumentAnalysis), diff --git a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs index 48bc733..513d80b 100644 --- a/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs +++ b/tests/ShellSyntaxTree.Tests/V03PublicApiSnapshotTests.cs @@ -63,7 +63,9 @@ public void Syntax_records_match_the_locked_members_and_defaults() ShellSyntaxKind.SimpleCommand, (nameof(SimpleCommandSyntax.Clause), typeof(Clause)), (nameof(SimpleCommandSyntax.Substitutions), - typeof(IReadOnlyList))); + typeof(IReadOnlyList)), + (nameof(SimpleCommandSyntax.ExecutionRegions), + typeof(IReadOnlyList))); AssertNode( new PipelineSyntax(), ShellSyntaxKind.Pipeline, @@ -104,11 +106,22 @@ public void Syntax_records_match_the_locked_members_and_defaults() new CommandSubstitutionSyntax(), ShellSyntaxKind.CommandSubstitution, (nameof(CommandSubstitutionSyntax.Body), typeof(ShellBlockSyntax))); + AssertNode( + new ExecutionRegionSyntax(), + ShellSyntaxKind.ExecutionRegion, + (nameof(ExecutionRegionSyntax.Origin), typeof(ExecutionRegionOrigin)), + (nameof(ExecutionRegionSyntax.HostClauseElementIndex), typeof(int?)), + (nameof(ExecutionRegionSyntax.Phase), typeof(ExecutionRegionPhase)), + (nameof(ExecutionRegionSyntax.Timing), typeof(ExecutionRegionTiming)), + (nameof(ExecutionRegionSyntax.Cardinality), typeof(ExecutionRegionCardinality)), + (nameof(ExecutionRegionSyntax.Body), typeof(ShellBlockSyntax))); var simple = new SimpleCommandSyntax(); Assert.NotNull(simple.Clause); Assert.NotNull(simple.Substitutions); Assert.Empty(simple.Substitutions); + Assert.NotNull(simple.ExecutionRegions); + Assert.Empty(simple.ExecutionRegions); Assert.Empty(new ShellBlockSyntax().Statements); Assert.Empty(new PipelineSyntax().Stages); Assert.Empty(new CommandListSyntax().Items); @@ -145,6 +158,15 @@ public void Syntax_records_match_the_locked_members_and_defaults() var substitution = new CommandSubstitutionSyntax(); Assert.NotNull(substitution.Body); Assert.Empty(substitution.Body.Statements); + + var executionRegion = new ExecutionRegionSyntax(); + Assert.Equal(ExecutionRegionOrigin.Unknown, executionRegion.Origin); + Assert.Null(executionRegion.HostClauseElementIndex); + Assert.Equal(ExecutionRegionPhase.Unknown, executionRegion.Phase); + Assert.Equal(ExecutionRegionTiming.Unknown, executionRegion.Timing); + Assert.Equal(ExecutionRegionCardinality.Unknown, executionRegion.Cardinality); + Assert.NotNull(executionRegion.Body); + Assert.Empty(executionRegion.Body.Statements); } [Fact] @@ -304,15 +326,25 @@ public void V03_enums_reserve_zero_for_unknown_and_match_the_locked_order() AssertEnum( "Unknown", "Block", "SimpleCommand", "Pipeline", "CommandList", "Group", "ForEach", "ConditionLoop", "Conditional", - "ConditionalBranch", "CommandSubstitution"); + "ConditionalBranch", "CommandSubstitution", "ExecutionRegion"); AssertEnum("Unknown", "CurrentScope", "IsolatedScope"); AssertEnum("Unknown", "While", "Until"); + AssertEnum( + "Unknown", "DirectCall", "DotSource", "CommandArgument"); + AssertEnum( + "Unknown", "Main", "Initialization", "Begin", "Process", "End", + "Filter", "Action", "Completion"); + AssertEnum( + "Unknown", "Synchronous", "Concurrent", "Deferred"); + AssertEnum( + "Unknown", "Once", "OncePerInputObject", "ZeroOrMore"); AssertEnum( "Unknown", "Ordinary", "PipelineStage", "Condition", "Iterator", - "LoopBody", "Branch", "Substitution"); + "LoopBody", "Branch", "Substitution", "ExecutionRegion"); AssertEnum( "Unknown", "Root", "Statement", "PipelineStage", "GroupBody", - "Iterator", "LoopBody", "Condition", "Branch", "Substitution"); + "Iterator", "LoopBody", "Condition", "Branch", "Substitution", + "ExecutionRegion"); AssertEnum("Unknown", "Exact", "FiniteSet", "Pattern"); AssertEnum("Unknown", "Literal", "Expand"); AssertEnum( @@ -343,6 +375,7 @@ public void V03_reference_property_nullability_matches_the_locked_contract() typeof(ConditionalSyntax), typeof(ConditionalBranchSyntax), typeof(CommandSubstitutionSyntax), + typeof(ExecutionRegionSyntax), typeof(CommandOccurrence), typeof(CommandAncestryFrame), typeof(EffectiveArgument),