Skip to content

Commit df9c61e

Browse files
Preserve source-ordered clause elements for issue #62 (#68)
* feat: preserve clause element source order * fix: complete case-sensitive native option rules * fix: harden native flag value classification * fix: integrate clause provenance after dev rebase * docs: define strict and general approval matching * fix: harden clause element provenance * refactor: remove obsolete flag helper * docs: track native fragment extraction
1 parent 71f3803 commit df9c61e

64 files changed

Lines changed: 6044 additions & 138 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

IMPLEMENTATION_PLAN.md

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ priorities.
3737
the 64 KiB cap (§11).
3838
- [x] **11. Multi-shell corpus runner + PII audit** — directory-routed by
3939
`Corpus/<shell>/`.
40-
- [x] **12. PowerShell corpus**211 entries under `Corpus/powershell/`,
40+
- [x] **12. PowerShell corpus**273 entries under `Corpus/powershell/`,
4141
every §13 category minimum exceeded.
4242
- [x] **13. `pwsh` validation gate + `tools/PwshCorpusTool`**
4343
`PwshOracleTests` enforces the §13 oracle matrix + the `PwshAliases`
@@ -54,6 +54,39 @@ priorities.
5454
Recurse into provably static `Invoke-Expression` / `iex` payloads,
5555
safe-fail computed and pipeline-fed code, share the existing recursion
5656
limits, and preserve current-scope PowerShell location attribution.
57+
- [x] **Issue #62 — source-ordered clause elements.** Added the additive
58+
`Clause.Elements` provenance view for Bash and PowerShell with exact raw
59+
spelling, decoded values, source spans when available, verb-relative
60+
argument placement, path facts, redirects, and conservative wrapper-span
61+
handling. Authored order is authoritative; element roles and
62+
`PrecedingVerbElementCount` explicitly mirror the greedy parser
63+
projection rather than executable semantics. Paired Bash/PowerShell
64+
corpus cases cover Git `-c`/`-C`, multiple occurrences, and a valueless
65+
option that stops the greedy walk. Existing projection shapes and
66+
synthetic cwd attribution remain compatible; native options that differ
67+
only by case receive corrected metadata. The post-implementation option
68+
audit explicitly covers Wget `-o` / `-O`, curl `-d` / `-D` / `-o` /
69+
`-O`, Git `-c` / `-C`, and tar `-c` / `-C` / `-f` / `-F`; paired corpus
70+
cases pin Wget log/document output, curl data/header-output and `@file`
71+
semantics, and tar helper-command safe-fail behavior in both shells.
72+
Adversarial review added deterministic coverage for quoted inline native
73+
fragment runs (including unquoted prefixes and mixed-quote safe-fail),
74+
PowerShell backtick-decoded colon bindings, native file-verb boundaries,
75+
and outer redirects on PowerShell command wrappers, including empty
76+
payloads. The
77+
corpus runner now verifies direct authored-token coverage even for legacy
78+
entries without explicit element expectations. Docker `-v`
79+
remains explicitly context-sensitive: the generic table supports
80+
`docker run`, while consumers use authored elements for global-option
81+
interpretation.
82+
Command-string provenance is integrated with the later
83+
`Invoke-Expression` recursion work: static expansion clears unmappable
84+
outer spans, while dynamic payloads retain conservative source-aligned
85+
elements. Nested and dynamic `bash -c` cases pin the equivalent Bash
86+
boundary. Consumer guidance separates strict authored-stream matching
87+
from general executable-aware normalization; Netclaw can use the latter
88+
for reusable approvals without treating parser verb roles as semantic
89+
command boundaries.
5790
- [x] **Issue #64 — path-shaped operands after native verb chains.**
5891
Stop the Bash and PowerShell native greedy passes before a token that
5992
matches the shared path-shape rules. Preserve that token as a resolved
@@ -70,14 +103,18 @@ priorities.
70103
corpus. Review follow-ups shipped with it: the equals-form split moved
71104
to a shared `NativeFlagSyntax` so the two parsers can't drift, a colon
72105
value under an `=`-bearing parameter name safe-fails to `DynamicSkip`,
73-
and `-?` lexes as one parameter token.
106+
`-?` lexes as one parameter token, and native option tables now use
107+
ordinal spelling while PowerShell cmdlet parameters remain
108+
case-insensitive.
74109

75110
### 15. Release 0.2.0 (alpha → beta → stable) — SPEC.PWSH §15 / §17
76111

77112
- [x] Tag `0.2.0-alpha`; `publish_nuget.yml` produced
78113
`ShellSyntaxTree.0.2.0-alpha.nupkg` and it is live on nuget.org
79114
(released 2026-05-20).
80115
- [x] `0.2.0-beta.1` so Netclaw validates the parser + the breaking rename
116+
- [ ] Publish the next `0.2.0` prerelease with the additive issue #62
117+
`Clause.Elements` provenance surface and migration guidance
81118
- [ ] Promote to stable `0.2.0` after Netclaw validation
82119

83120
### 16. Netclaw v0.2.0 integration — SPEC.PWSH §17 #9
@@ -105,6 +142,9 @@ priorities.
105142
- PowerShell script-level constructs — control flow, `function` / `class` /
106143
`enum` definitions, `param()` / `begin` / `process` / `end` blocks,
107144
`.ps1` file parsing (`SPEC.POWERSHELL.md` §18).
145+
- [Issue #69](https://github.com/Aaronontheweb/ShellSyntaxTree/issues/69)
146+
extract shared native argument-fragment classification before adding a third
147+
shell or another fragment rule; keep shell tokenization and parsing local.
108148
- Extract a shared lexer/parser core now that two parsers exist — the seam
109149
can be designed from real duplication (`SPEC.POWERSHELL.md` §18); the
110150
path-normalization helpers duplicated between `BashResolver` and

PROJECT_CONTEXT.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ The output is a `ParsedCommand` containing:
1919
when known, and explicit `DynamicSkip` marking for unresolved env vars
2020
/ unexpanded globs
2121
- redirect operators (`>`, `>>`, `<`, `2>`, `2>>`)
22+
- source-ordered clause elements with exact spelling, decoded values, source
23+
spans when available, and coordinates relative to parser-classified verb
24+
elements; executable-specific semantics remain consumer-owned
2225
- Bash `cd <dir> && cmd` and PowerShell `Set-Location <dir>; cmd`
2326
propagation — the target is attributed to subsequent clauses
2427
- recursion into `bash -c`, `pwsh -Command`, and `pwsh -EncodedCommand` so
@@ -39,7 +42,9 @@ open-source autonomous operations agent. Netclaw's POSIX approval gate consumes
3942
ShellSyntaxTree's Bash parser to decompose approval units, identify candidate
4043
verbs and directories, propagate cwd context, inspect redirects, and fail
4144
closed when parsing is uncertain. Its PowerShell integration is the remaining
42-
v0.2.0 downstream acceptance item. See
45+
v0.2.0 downstream acceptance item. Netclaw is expected to use the consumer
46+
guide's general executable-aware matching path for supported commands, with
47+
strict authored-stream matching as the fallback for unrecognized shapes. See
4348
[`docs/CONSUMER_GUIDE.md`](./docs/CONSUMER_GUIDE.md) for the public consumer
4449
algorithm and immutable Netclaw examples.
4550

@@ -83,8 +88,9 @@ zero-native-deps .NET parser sized to what security gates actually need.
8388
and `.ps1` file-content parsing.
8489
- Performance optimization beyond "fast enough to invoke per shell call
8590
without noticeable latency" (~1 ms typical).
86-
- Full IDE-style source mapping. Security-motivated token provenance is under
87-
active design in issue #62.
91+
- Full IDE-style source mapping. `Clause.Elements` provides security-motivated
92+
provenance for significant clause leaves, not a lossless concrete syntax
93+
tree.
8894

8995
### Versioning
9096

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ dotnet add package ShellSyntaxTree --version 0.2.0-beta.1
1616
```
1717

1818
The `0.2.0-beta.1` prerelease is required for PowerShell support. The latest
19-
stable `0.1.x` package supports Bash only.
19+
stable `0.1.x` package supports Bash only. The public surface documented below
20+
tracks the `dev` branch; `Clause.Elements` is scheduled for the next `0.2.0`
21+
prerelease and is not present in `beta.1`.
2022

2123
## What you get
2224

@@ -120,12 +122,14 @@ public sealed record BashParserOptions : ShellParserOptions;
120122
public sealed record PwshParserOptions : ShellParserOptions;
121123

122124
public sealed record ParsedCommand { /* Source, Clauses, IsUnparseable, … */ }
123-
public sealed record Clause { /* Operator, Verb, Args, Redirects, IsSubshell, IsCommandStringWrapped */ }
125+
public sealed record Clause { /* Operator, Verb, Args, Redirects, Elements, IsSubshell, IsCommandStringWrapped */ }
126+
public sealed record ClauseElement { /* Raw, Value, Role, source span, verb-relative position, path facts */ }
124127
public sealed record VerbChain { /* Tokens, Joined, CanonicalVerb, IsDynamic */ }
125128
public sealed record Arg { /* Raw, Resolved, Kind, IsPath, IsCwdAttribution, IsFlag */ }
126129
public sealed record Redirect { /* Direction, Target, IsDynamicSkip */ }
127130

128131
public enum ArgKind { Literal, EnvVar, Glob, Tilde, DynamicSkip }
132+
public enum ClauseElementRole { Verb, Argument, Redirect }
129133
public enum RedirectDirection { In, Out, Append, ErrOut, ErrAppend }
130134
public enum CompoundOperator { None, AndIf, OrIf, Sequence, Pipe }
131135
```

SPEC.POWERSHELL.md

Lines changed: 78 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,11 @@ syntax (§5) are all PowerShell 7 semantics. The `pwsh` validation oracle
7575

7676
## 2. Public API Surface
7777

78-
The shared interface, AST records, and enums are defined in **`SPEC.md` §2**
79-
and are unchanged. PowerShell adds the following to namespace
80-
`ShellSyntaxTree`; everything else is internal.
78+
The shared interface, AST records, and enums are defined in **`SPEC.md` §2**.
79+
The additive `Clause.Elements`, `ClauseElement`, and `ClauseElementRole`
80+
provenance surface applies identically to both parsers. PowerShell adds the
81+
following parser types to namespace `ShellSyntaxTree`; everything else is
82+
internal.
8183

8284
```csharp
8385
namespace ShellSyntaxTree;
@@ -117,14 +119,17 @@ public sealed class PwshParser : IShellParser
117119
}
118120
```
119121

120-
Two shared types gain a change (see §3):
122+
The shared v0.2 AST gains the following changes (see §3):
121123

122124
- `VerbChain` gains an additive `string? CanonicalVerb` field.
123125
- `VerbChain` gains an additive `bool IsDynamic` field.
126+
- `Clause` gains the additive `Elements` provenance view shared with Bash;
127+
`ClauseElement` and `ClauseElementRole` define its entries.
124128
- `Clause.IsBashCWrapped` is renamed `Clause.IsCommandStringWrapped`.
125129

126130
**Versioning.** `PwshParser`, `PwshParserOptions`, `ShellParserOptions`,
127-
`VerbChain.CanonicalVerb`, and `VerbChain.IsDynamic` are additive. The
131+
`VerbChain.CanonicalVerb`, `VerbChain.IsDynamic`, `Clause.Elements`,
132+
`ClauseElement`, and `ClauseElementRole` are additive. The
128133
`Clause` field rename and the `BashParserOptions` reparenting are
129134
**breaking**; `SPEC.md` Appendix A permits a breaking AST change on a `0.x`
130135
minor bump when `RELEASE_NOTES.md` carries the old→new mapping and Netclaw is
@@ -136,9 +141,47 @@ never throws on a well-formed string, exactly like `BashParser`.
136141

137142
## 3. AST Reference
138143

139-
The AST records and enums are defined in **`SPEC.md` §3** and are emitted
140-
unchanged by `PwshParser` — a consumer walks a PowerShell `ParsedCommand`
141-
exactly as it walks a bash one. Two deltas:
144+
The AST records and enums are defined in **`SPEC.md` §3** and are emitted by
145+
`PwshParser` under the same shared contract — a consumer walks a PowerShell
146+
`ParsedCommand` exactly as it walks a bash one. PowerShell has the following
147+
deltas and provenance rules:
148+
149+
### `Clause.Elements` PowerShell rules
150+
151+
PowerShell parameters, native options, quoted/here-string values, and opaque
152+
dynamic regions each occupy their authored position in `Clause.Elements`.
153+
The leading call operator in `& command` and grouping parentheses are shell
154+
syntax rather than verb/argument/redirect leaves and do not appear.
155+
156+
Inline parameter forms remain one source element. For `-Path:C:\repo`, the
157+
element's `Raw` and `Value` describe the full parameter token while `Kind`,
158+
`IsPath`, and `Resolved` describe the bound `C:\repo` value. Native
159+
`--flag=value` follows the same rule as Bash. Backtick escapes in an inline
160+
bound value are decoded before `Value` and path metadata are produced. Adjacent
161+
native fragments such as `--data='@C:\payload file'` form one element because
162+
PowerShell passes them to the executable as one argument. The complete
163+
contiguous fragment run is consumed. Resolver-sensitive syntax inside a
164+
single-quoted fragment mixed with expandable fragments safe-fails as
165+
`DynamicSkip` rather than being expanded.
166+
167+
Clauses recursively surfaced from `pwsh -Command` and
168+
`pwsh -EncodedCommand` retain inner `Raw` and `Value` but have null
169+
`SourceStart` and `SourceLength`: quote/backtick processing, script-block
170+
stripping, and base64 decoding do not provide a generally exact map into the
171+
outer `ParsedCommand.Source`.
172+
An outer redirect authored after a `pwsh -Command` or `-EncodedCommand`
173+
payload remains on the surfaced wrapped clause with its exact outer source
174+
span; only decoded inner elements have null spans.
175+
176+
`ClauseElement.Role` and `PrecedingVerbElementCount` mirror the shared greedy
177+
native verb projection. They are AST coordinates, not native-executable
178+
semantic boundaries. A PowerShell consumer applies executable-specific grammar
179+
to the complete authored element order exactly as a Bash consumer does.
180+
181+
PowerShell cmdlet names, aliases, and parameter names remain
182+
case-insensitive. Native option spelling is ordinal and reuses the shared Bash
183+
native tables unchanged: PowerShell does not make a native executable's `-c`
184+
and `-C` options equivalent.
142185

143186
### `VerbChain.CanonicalVerb` (new, additive)
144187

@@ -672,11 +715,21 @@ positionals are paths," exactly as `SPEC.md` §7.
672715

673716
Native commands reuse the bash per-verb rules table verbatim — `git`,
674717
`curl`, `tar`, etc. behave identically to `SPEC.md` §7 (`curl` / `wget`:
675-
the first positional is a URL; the `-o` / `-O` value is a path). This
718+
the first positional is a URL; curl `-o` / `-D` values and Wget `-o` / `-O`
719+
values are paths, while curl `-d` data is non-path unless `@file` requests a
720+
file read; `@-` denotes stdin). Tar `-F` / `--info-script` /
721+
`--new-volume-script` values execute commands and therefore safe-fail as
722+
`DynamicSkip`, not paths. This
676723
includes hyphenated option names and the bash `--flag=value` split: the
677724
flag and value surface as separate args, and a curated flag's value receives
678725
the same path classification in both parsers. Native `--flag:value` has no
679-
cmdlet-binding semantics and remains verbatim.
726+
cmdlet-binding semantics and remains verbatim. PowerShell still owns outer
727+
tokenization: spaced curl operands beginning with `@` should be quoted because
728+
`@name` is splatting and bare `@-` is a parse error. Use forms such as
729+
`-d "@request.json"` / `-d "@-"`, or bind a file inline as
730+
`--data=@request.json`, so the native command receives one value. An equals
731+
prefix adjacent to a quoted value, such as `--data='@C:\payload file'`, is
732+
also one native argument and one clause element.
680733

681734
---
682735

@@ -853,8 +906,8 @@ not just one quoted token. The parser handles all three real forms:
853906
- **Script block**`pwsh -Command { Remove-Item C:\tmp\x }`. Parse the
854907
script-block *interior* (braces stripped) as a fresh `ParsedCommand`.
855908
- **Bare / multi-token**`pwsh -Command Remove-Item C:\tmp\x`. Take the
856-
verbatim source slice from the first token after `-Command` to the end of
857-
the statement and parse *that* as a fresh `ParsedCommand`.
909+
verbatim source slice from the first token after `-Command` through the last
910+
command token and parse *that* as a fresh `ParsedCommand`.
858911

859912
In every form the inner clauses surface inline, each with
860913
`IsCommandStringWrapped = true`. **Not** recognizing the bare/multi-token
@@ -865,6 +918,12 @@ yields `IsUnparseable = true`** (e.g. a `-Command` payload that decodes to a
865918
control-flow script), sets the outer `ParsedCommand.IsUnparseable = true` so
866919
the whole command routes to safe-fail (`SPEC.md` §10).
867920

921+
A terminal redirect belongs to the outer PowerShell invocation, not the child
922+
command string. The parser appends each such redirect to the last surfaced
923+
inner clause's `Redirects` and `Elements`; its outer source span remains exact.
924+
Non-redirect arguments after a quoted, script-block, colon-bound, or encoded
925+
payload are not modeled and set `IsUnparseable=true` rather than disappearing.
926+
868927
`pwsh -File script.ps1` is **not** recursion — the file content is not
869928
available to the parser. It parses as an ordinary clause with `script.ps1`
870929
as a path arg.
@@ -911,6 +970,9 @@ leaves the required payload missing. Dynamic inline values remain opaque.
911970
For a static payload, the parser consumes the outer expression clause and
912971
surfaces the inner clauses inline with `IsCommandStringWrapped = true`. The
913972
first inner clause takes the operator that preceded the outer expression.
973+
Surfaced `Clause.Elements` retain their inner raw and decoded values but have
974+
null source spans because their offsets cannot be mapped exactly into the
975+
outer `ParsedCommand.Source`.
914976
The parse increments the same depth counter used by `pwsh -Command` and
915977
`-EncodedCommand`, and the payload passes through the same 64 KiB input cap.
916978

@@ -924,7 +986,10 @@ The parser never evaluates variables, interpolation, concatenation,
924986
subexpressions, script blocks, arrays, or other computed expressions. When a
925987
direct computed payload has a source expression, the outer expression clause
926988
remains and the entire payload source slice becomes one
927-
`Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }`. Pipeline input,
989+
`Arg { Kind=DynamicSkip, IsPath=false, Resolved=null }`. Its authored
990+
`Clause.Elements` retain the expression verb, an optional separate `-Command`
991+
parameter, and one source-aligned `DynamicSkip` payload region. An inline form
992+
such as `-Command:$code` remains one authored parameter element. Pipeline input,
928993
missing payloads, and ambiguous parameter binding set
929994
`ParsedCommand.IsUnparseable = true`; an incoming pipeline is dynamic even
930995
when an explicit literal argument also appears. These rules prevent a clean,

0 commit comments

Comments
 (0)