diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index 294e4a8..59d662b 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -114,20 +114,26 @@ bulldoze priorities. - [x] Flag-value path classification table (`git -C` is path; `curl -d` is body data; `docker -v` is single literal IsPath=false per #8) -### 8. cd-in-compound propagation (SPEC §9) - -- [ ] First-clause `cd` sets attributed cwd for the compound -- [ ] Subsequent clauses receive synthetic `Arg` with - `IsCwdAttribution=true` -- [ ] Subsequent `cd` in the same compound replaces attribution -- [ ] Subshell boundaries reset attribution - -### 9. Subshell + bash -c surfacing (SPEC §10) - -- [ ] Flatten subshell clauses into parent's `Clauses` with - `IsSubshell=true` -- [ ] Surface `bash -c` inner clauses inline with `IsBashCWrapped=true` -- [ ] Recursion-depth cap +### 8. cd-in-compound propagation (SPEC §9) — PR 5, complete + +- [x] First-clause `cd`/`chdir` sets attributed cwd; only those two verbs + propagate (interp #5) +- [x] Subsequent clauses receive synthetic `Arg` with + `IsCwdAttribution=true`; `Kind=Literal, IsPath=true` when cd + target resolved, `Kind=DynamicSkip, IsPath=false` when dynamic + (interp #6) +- [x] Subsequent `cd` in the same compound replaces attribution +- [x] Subshell boundaries isolate attribution via push/pop stack + +### 9. Subshell + bash -c surfacing (SPEC §10) — PR 5, complete + +- [x] Flatten subshell clauses into parent's `Clauses` with + `IsSubshell=true`; sibling subshells handled via SubshellStack IDs +- [x] Surface `bash -c "..."` / `sh -c "..."` inner clauses inline with + `IsBashCWrapped=true`; outer cd attribution doesn't leak into + inner shell (v0.1 decision) +- [x] Recursion depth cap at 5 → outer + `ParsedCommand.IsUnparseable=true` (interp #4) ### 10. Hand-authored corpus (SPEC §13 — minimum 105 entries) diff --git a/SPEC.md b/SPEC.md index bd8b3a0..5ebaf43 100644 --- a/SPEC.md +++ b/SPEC.md @@ -680,8 +680,15 @@ The agent's natural idiom is `cd /target && cmd1 && cmd2`. Bash semantics: ### Rules -1. **First clause is a `cd`-family verb** (per §6.2): the cd target becomes - the **attributed cwd** for subsequent clauses in the same compound. +1. **First clause is a `cd` or `chdir` verb**: the cd target becomes the + **attributed cwd** for subsequent clauses in the same compound. **Only + `cd` and `chdir`** propagate attribution per locked interpretation #5. + `pushd`, `popd`, `push-location`, and `set-location` are still listed + in `CwdVerbs` so their first non-flag positional is path-classified + (the target shows up as `IsPath=true`), but they do **not** add a + synthetic attribution arg to subsequent clauses. A future v0.1.x or + v0.2 with PowerShell support may model `pushd`/`popd` as a proper + directory stack. 2. **Subsequent clauses inherit the attributed cwd** as if it were prepended with `-C` semantics. Specifically: a synthetic `Arg` with `IsPath=true`, `Resolved=`, and `Kind=Literal` is added to @@ -693,17 +700,40 @@ The agent's natural idiom is `cd /target && cmd1 && cmd2`. Bash semantics: 3. **A subsequent `cd`** in the same compound **replaces** the attributed cwd for clauses after it. (`cd /a && cmd1 && cd /b && cmd2` → cmd1 - inherits `/a`, cmd2 inherits `/b`.) + inherits `/a`, cmd2 inherits `/b`.) The replacing `cd /b` itself still + *receives* `/a` as a synthetic attribution arg (rule 2) before becoming + the new source — additive semantics per rule 5. 4. **Subshell boundaries reset attribution.** `cd /a && (cd /b && cmd1) && cmd2`: cmd1 (inside subshell) inherits `/b`; cmd2 (outside subshell) inherits - `/a` (the subshell's `cd /b` does not leak out). + `/a` (the subshell's `cd /b` does not leak out). A subshell *inherits* + outer attribution on entry (so `cd /a && (cmd)` still attributes cmd + to /a) but its own cd changes stay isolated. 5. **Attribution does not change the clause's verb or original args.** The attribution is purely additive — the `cd` clause itself is still parsed normally, and subsequent clauses retain everything the user typed, plus the synthetic Arg. +### Dynamic-cd attribution (locked interpretation #6) + +When the cd target itself is `Kind=DynamicSkip` (e.g. `cd $REPO`), we +statically don't know the resolved cwd. To preserve the cwd-uncertainty +signal for subsequent clauses: + +- A synthetic `Arg { Raw="", Resolved=null, Kind=DynamicSkip, + IsPath=false, IsCwdAttribution=true }` is appended to each subsequent + clause (instead of the literal-cd flavor). +- Relative path args in subsequent clauses are not re-resolved against a + fall-back cwd; they surface as `Kind=DynamicSkip, IsPath=false, + Resolved=null` so consumers route to safe-fail rather than trust a + guessed working directory. + +Consumers that iterate `IsPath=true` args won't see the synthetic +attribution arg; consumers that specifically check `IsCwdAttribution` +can detect "this clause's cwd context is unknown" and elevate to +user-prompt instead of treating it like a default-cwd command. + ### Example Input: `cd /target && git -C /other log && cat file.txt` @@ -739,15 +769,11 @@ already see the resolved path in another arg. ### Subshells Subshells are clauses wrapped in parens: `(cd /a && cmd)`. The parser -recognizes the parens, parses the inner command, and emits a single -clause with: - -- `Verb` = empty (a subshell has no verb of its own) -- `Args` = empty -- `IsSubshell = true` -- A nested `ParsedCommand` field — **but** since the AST should be flat - for consumer convenience, instead we **flatten** the subshell into the - parent's `Clauses` list with each inner clause's `IsSubshell=true`. +recognizes the parens and **flattens** the subshell's inner clauses into +the parent's `Clauses` list, marking each with `IsSubshell=true` so +consumers can distinguish them from outer-compound clauses. A subshell +*inherits* the outer compound's cd attribution on entry but its own cd +changes stay isolated to the subshell (rule 4 above). Specifically: `(cd /b && cmd) && cmd2` produces three clauses: @@ -779,7 +805,10 @@ by the recursion. Consumers that care that this came from a wrapper can inspect `IsBashCWrapped` on the surfaced clauses. **Recursion limit:** parse `bash -c "bash -c ..."` chains up to depth 5. -Deeper nesting → mark the deepest clause as `IsUnparseable = true`. +Deeper nesting → set the outer `ParsedCommand.IsUnparseable = true` with +reason `"bash -c recursion depth exceeded (>5)"` per locked interpretation +#4. (`Clause` has no `IsUnparseable` field; we surface the overflow on the +top-level ParsedCommand so consumers safe-fail per §11.) --- diff --git a/openspec/changes/v0.1-locked-interpretations/tasks.md b/openspec/changes/v0.1-locked-interpretations/tasks.md index 318d7e5..f8724b3 100644 --- a/openspec/changes/v0.1-locked-interpretations/tasks.md +++ b/openspec/changes/v0.1-locked-interpretations/tasks.md @@ -161,23 +161,61 @@ the SPEC.md sections that get updated alongside the implementation. ## 5. PR 5 — cd attribution + subshells + bash -c (interpretations #4, #5, #6) -- [ ] 5.1 `Internal/Bash/Parsing/CdAttributionContext.cs` (parser-internal - mutable; output AST stays immutable) -- [ ] 5.2 Only `cd`/`chdir` propagate (interpretation #5); pushd/popd - parse but don't propagate -- [ ] 5.3 Synthetic `Arg{ IsCwdAttribution=true, … }` appended to - subsequent clauses; `Kind=DynamicSkip` when cd target is dynamic - (interpretation #6) -- [ ] 5.4 Subshell `(...)` parsing with attribution stack push/pop -- [ ] 5.5 `bash -c "..."` / `sh -c "..."` recursion (cap at 5) -- [ ] 5.6 Recursion overflow → outer `ParsedCommand.IsUnparseable=true` - (interpretation #4) -- [ ] 5.7 Update `SPEC.md` §9 (clarify which CwdVerbs propagate; add - dynamic-cd attribution subsection); §10 (replace "mark the deepest - clause" wording with "set ParsedCommand.IsUnparseable=true") -- [ ] 5.8 File GitHub issue: "Model pushd/popd directory stack semantics" -- [ ] 5.9 Open OpenSpec change `cd-attribution-clarifications` for the - §9/§10 deltas +- [x] 5.1 `Internal/Bash/Parsing/CdAttributionContext.cs` — parser-internal + mutable; SubshellStack of monotonic IDs (handles sibling subshells + cleanly); SetLiteralAttribution / SetDynamicAttribution; HasAttribution +- [x] 5.2 Only `cd`/`chdir` propagate (interpretation #5); pushd/popd + parse as CwdVerbs but don't propagate. Test + `Pushd_does_not_propagate_attribution` pins this. +- [x] 5.3 Synthetic `Arg{ IsCwdAttribution=true, … }` appended to + subsequent clauses. `Kind=Literal, IsPath=true, Resolved=` + when cd target resolved; `Kind=DynamicSkip, IsPath=false, + Resolved=null, Raw=""` when cd target was DynamicSkip + per interpretation #6. +- [x] 5.4 Subshell `(...)` parsing with attribution stack push/pop. + `cd /a && (cd /b && cmd1) && cmd2` — cmd1 sees /b attribution; + cmd2 sees /a (subshell's /b doesn't leak out). `IsSubshell=true` + set on every clause inside a subshell. +- [x] 5.5 `bash -c "..."` / `sh -c "..."` recursion replaces PR 3's + single-clause framework. Outer `bash -c` consumed; inner clauses + surfaced inline with `IsBashCWrapped=true`. Outer cd attribution + does NOT leak into inner shell (v0.1 decision). +- [x] 5.6 Recursion depth cap at 5 → outer + `ParsedCommand.IsUnparseable=true` with reason `"bash -c recursion + depth exceeded (>5)"` per interpretation #4. +- [x] 5.7 SPEC §9 updated (rule 3 explicit on which verbs propagate; + dynamic-cd attribution subsection added per interp #6); §10 + updated (subshell flattening rephrased; bash -c recursion-limit + replaced with "set ParsedCommand.IsUnparseable" per interp #4). +- [x] 5.8 BashResolver internal overload `Resolve(raw, treatAsPath, + options, workingDirectoryUnknown)` lets the propagator force + DynamicSkip on relative-path args under dynamic-cd (interp #6) + without polluting the public `BashParserOptions` surface. +- [x] 5.9 30 new corpus entries (71-100): 10 cd-in-compound + 10 + subshell + 10 bash -c. 5 PR 3-4 corpus entries refreshed + (25, 28, 34, 35, 52). +- [x] 5.10 8 new parser tests covering attribution propagation, subshell + isolation, sequential cd, pushd non-propagation, dynamic cd, + sibling subshells, bash -c depth-2 success, depth-6 overflow. +- [x] 5.11 **337/337 tests passing** (was 296 at PR 4 baseline). Public + API surface unchanged. +- [ ] 5.12 File GitHub issue: "Model pushd/popd directory stack semantics" + (interp #5 option-C upgrade) + +### PR 5 follow-ups (tracked for PR 6) + +- Possible SPEC clarification: pin the sentinel `Raw` value for + dynamic-cd synthetic attribution args (current implementation uses + `""` but SPEC is silent). +- Outer cd attribution into bash -c inner clauses (v0.1: doesn't + propagate; v0.1.x or v0.2 may revisit). +- `cd -` (jump-to-previous-dir): currently treated as "no attribution + change" because `-` is detected as a flag (no non-flag positional). + v0.1.x may want explicit handling. +- Existing PR 3 corpus entries 21-24, 26-27, 29-33 (the compound + entries that had no `cd` prefix) likely don't need updates; but PR 6 + should audit all 100 corpus entries for AST drift now that PRs 1-5 + have all landed. ## 6. PR 6 — Corpus completeness + PII audit (interpretation #7) diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index d75584b..7b6a56e 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -13,13 +13,23 @@ namespace ShellSyntaxTree.Internal.Bash.Parsing; /// /// Translates a token stream into the public -/// AST. PR 4 wires per-verb path classification +/// AST. PR 4 wired per-verb path classification /// (SPEC §7), the resolver (SPEC §8), and the flag-with-value-aware verb- -/// chain probe on top of the PR 3 core. PR 5 will land subshell flag -/// flipping, bash -c recursion, and cd-in-compound propagation. +/// chain probe. PR 5 lands cd-in-compound attribution propagation +/// (SPEC §9), subshell isolation (SPEC §10), and bash -c recursion +/// with the depth-5 cap per locked interpretation #4. /// internal static class BashCommandParser { + /// + /// Maximum allowed bash -c / sh -c nesting depth before + /// the parser safe-fails per locked interpretation #4. Hard-coded — + /// SPEC §10 picks 5 because hostile input would have to chain 5+ wrapper + /// invocations to evade analysis and that's well beyond any legitimate + /// agent emission. + /// + private const int MaxBashCRecursionDepth = 5; + /// /// Parse the input string into a . Never /// throws on well-formed input; safe-fails to IsUnparseable=true @@ -37,6 +47,23 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) throw new ArgumentNullException(nameof(options)); } + return ParseInternal(source, options, bashCDepth: 0, markBashCWrapped: false); + } + + /// + /// Recursion entry point. counts how many + /// bash -c wrappers we've unwrapped to reach this call; the + /// outer caller passes 0. sets + /// on every emitted clause and + /// fires only on recursive calls (the outer top-level command doesn't + /// pretend to be wrapped). + /// + private static ParsedCommand ParseInternal( + string source, + BashParserOptions options, + int bashCDepth, + bool markBashCWrapped) + { if (source.Length == 0) { return new ParsedCommand @@ -52,8 +79,8 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) // Step 1: lift any UnparseableSentinel to the outer ParsedCommand. // SPEC §11 step 3 says we may also return whatever clauses were // parsed up to that point — we keep it strictly safe-fail (empty - // Clauses) so consumers don't get a half-built AST whose shape - // changes when PR 5 wires recursion. The reason text comes + // Clauses) so consumers can't build on a partial AST whose shape + // a sibling clause might invalidate. The reason text comes // straight from the lexer. for (var i = 0; i < tokens.Count; i++) { @@ -98,11 +125,138 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) }; } - // Step 4: parse each segment into a Clause. + // Step 4: walk segments with the cd-attribution context and the + // bash -c recursion machinery. var clauses = new List(segments.Count); + var attribution = new CdAttributionContext(); + IReadOnlyList prevStack = new[] { 0 }; + foreach (var segment in segments) { - var clauseOrError = ParseClauseSegment(segment, source, options); + // ---- Subshell push/pop driven by SubshellStack divergence ---- + // + // Each segment carries the *full* stack of subshell IDs it + // sits inside (outer-most → inner-most), with ID 0 reserved + // for the top-level command. We pop pushed frames back to the + // common prefix between prevStack and segment.SubshellStack, + // then push fresh frames for each new ID we're entering. This + // correctly handles `(a) && (b)`: between the two segments + // we exit subshell A (pop) and enter subshell B (push), even + // though SubshellDepth=1 on both. + var commonPrefix = 0; + while (commonPrefix < prevStack.Count + && commonPrefix < segment.SubshellStack.Count + && prevStack[commonPrefix] == segment.SubshellStack[commonPrefix]) + { + commonPrefix++; + } + + // Pop everything past the common prefix in prevStack. + for (var k = prevStack.Count - 1; k >= commonPrefix; k--) + { + attribution.PopForSubshell(); + } + + // Push fresh frames for the new IDs in segment.SubshellStack. + for (var k = commonPrefix; k < segment.SubshellStack.Count; k++) + { + attribution.PushForSubshell(); + } + + prevStack = segment.SubshellStack; + + // ---- bash -c detection (before clause-build) ---- + // + // Locked interpretation #4: nested `bash -c "..."` wrappers + // expand inline; the outer wrapper clause is consumed. The cap + // at depth 5 fires here — one more level past 5 → outer + // ParsedCommand.IsUnparseable = true with reason naming the + // overflow. Sub-clauses parsed *up to* the cap may still appear, + // but per SPEC §11 + locked interpretation #4 we keep clauses + // empty for hostile-input safety. + if (TryDetectBashCWrapper(segment, source, out var innerCommand)) + { + if (bashCDepth + 1 > MaxBashCRecursionDepth) + { + return new ParsedCommand + { + Source = source, + Clauses = Array.Empty(), + IsUnparseable = true, + UnparseableReason = "bash -c recursion depth exceeded (>5)", + }; + } + + // Recurse with the original options — bash -c spawns a + // fresh shell, so outer cd-attribution does *not* propagate + // into the inner command. This is a v0.1 decision; v0.1.x + // can revisit if real-world commands surface a counter-case. + var inner = ParseInternal( + innerCommand!, + options, + bashCDepth: bashCDepth + 1, + markBashCWrapped: true); + + if (inner.IsUnparseable) + { + return new ParsedCommand + { + Source = source, + Clauses = Array.Empty(), + IsUnparseable = true, + UnparseableReason = inner.UnparseableReason, + }; + } + + // First inner clause inherits the outer segment's operator + // (since the bash -c clause itself is consumed). Remaining + // inner clauses keep their parsed operators. + var innerClauses = inner.Clauses; + for (var k = 0; k < innerClauses.Count; k++) + { + var ic = innerClauses[k]; + var op = k == 0 ? segment.PrecedingOperator : ic.Operator; + var isSubshell = segment.SubshellDepth > 0 || ic.IsSubshell; + clauses.Add(ic with + { + Operator = op, + IsSubshell = isSubshell, + IsBashCWrapped = true, + }); + } + + continue; + } + + // ---- Normal clause path with attribution propagation ---- + // + // Effective resolution options depend on attribution state: + // - no attribution → caller options pass through unchanged. + // - literal cd attribution → swap WorkingDirectory to the cd + // target so relative path args in subsequent clauses + // resolve under it (SPEC §9 example: `cd /a && cat foo` + // → cat's `foo` resolves to `/a/foo`). + // - dynamic cd attribution (locked interpretation #6) → + // keep caller options but set the resolver's + // `workingDirectoryUnknown` flag so relative paths surface + // as DynamicSkip (the daemon cwd is *not* the right + // fallback; we statically don't know the actual cwd). + var effectiveOptions = options; + var workingDirectoryUnknown = false; + if (attribution.HasAttribution && !attribution.IsDynamic) + { + effectiveOptions = new BashParserOptions + { + HomeDirectory = options.HomeDirectory, + WorkingDirectory = attribution.ResolvedCwd, + }; + } + else if (attribution.IsDynamic) + { + workingDirectoryUnknown = true; + } + + var clauseOrError = ParseClauseSegment(segment, source, effectiveOptions, workingDirectoryUnknown); if (clauseOrError.Error is not null) { return new ParsedCommand @@ -116,7 +270,36 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) foreach (var clause in clauseOrError.Clauses) { - clauses.Add(clause); + // Apply the IsSubshell / IsBashCWrapped flags first; both + // are properties of the *segment*, not the clause body. + var withFlags = clause with + { + IsSubshell = segment.SubshellDepth > 0, + IsBashCWrapped = markBashCWrapped, + }; + + // Inspect the verb to decide whether this clause updates + // the attribution context after emission. + var verb = clause.Verb; + var firstVerbToken = verb.Tokens.Count > 0 ? verb.Tokens[0] : null; + var isCdLike = firstVerbToken is not null + && (string.Equals(firstVerbToken, "cd", StringComparison.OrdinalIgnoreCase) + || string.Equals(firstVerbToken, "chdir", StringComparison.OrdinalIgnoreCase)); + + // Every clause receives the *current* attribution as a + // synthetic arg — including cd/chdir clauses themselves + // (per SPEC §9 rule 2 "subsequent clauses inherit", which + // applies to cd /b in `cd /a && cd /b`). The attribution + // state is updated *after* the arg is attached, so the + // newly-set cd /b doesn't attribute to itself. + var emitted = AttachAttributionArg(withFlags, attribution); + + if (isCdLike) + { + UpdateAttributionFromCd(clause, attribution); + } + + clauses.Add(emitted); } } @@ -128,6 +311,184 @@ internal static ParsedCommand Parse(string source, BashParserOptions options) }; } + // ---------------------------------------------------------------- cd attribution + + /// + /// Inspect a freshly-parsed cd / chdir clause and update + /// from its first non-flag positional + /// arg. Per locked interpretation #5 only cd/chdir reach here; + /// pushd/popd/push-location/set-location parse as CwdVerbs but the + /// caller skips this update. + /// + private static void UpdateAttributionFromCd(Clause clause, CdAttributionContext attribution) + { + Arg? firstPositional = null; + foreach (var a in clause.Args) + { + if (!a.IsFlag) + { + firstPositional = a; + break; + } + } + + if (firstPositional is null) + { + // `cd` with no target → bash semantics is "cd to $HOME". We + // could expand to HomeDirectory here, but security-gate + // consumers care about *explicit* cwds; treat as no attribution + // change (the previous attribution, if any, persists). A + // synthetic arg is also not appended downstream since + // HasAttribution stays as it was. + return; + } + + switch (firstPositional.Kind) + { + case ArgKind.Literal: + case ArgKind.Tilde: + // Literal or tilde-expanded path. Resolved should be the + // normalized absolute path. When it isn't (resolver fell + // through), treat as dynamic so we don't carry a stale + // attribution. + if (firstPositional.Resolved is not null) + { + attribution.SetLiteralAttribution(firstPositional.Resolved); + } + else + { + attribution.SetDynamicAttribution(); + } + break; + case ArgKind.DynamicSkip: + case ArgKind.EnvVar: + case ArgKind.Glob: + // Locked interpretation #6 (and the symmetric Glob case): + // we can't statically know the resolved cwd. Subsequent + // clauses get a synthetic DynamicSkip attribution arg. + attribution.SetDynamicAttribution(); + break; + } + } + + /// + /// Append a synthetic arg to + /// when has + /// active state. Literal-cd attribution appends a Literal/IsPath=true + /// arg with Resolved set; dynamic-cd attribution (locked interpretation + /// #6) appends a DynamicSkip arg with Resolved=null. When no + /// attribution is active, returns unchanged. + /// + private static Clause AttachAttributionArg(Clause clause, CdAttributionContext attribution) + { + if (!attribution.HasAttribution) + { + return clause; + } + + Arg synthetic; + if (attribution.IsDynamic) + { + synthetic = new Arg + { + Raw = "", + Resolved = null, + Kind = ArgKind.DynamicSkip, + IsPath = false, + IsCwdAttribution = true, + }; + } + else + { + var cwd = attribution.ResolvedCwd!; + synthetic = new Arg + { + Raw = cwd, + Resolved = cwd, + Kind = ArgKind.Literal, + IsPath = true, + IsCwdAttribution = true, + }; + } + + var newArgs = new List(clause.Args.Count + 1); + newArgs.AddRange(clause.Args); + newArgs.Add(synthetic); + return clause with { Args = newArgs }; + } + + // ---------------------------------------------------------------- bash -c detection + + /// + /// Detect whether is a bash -c "..." + /// or sh -c "..." wrapper. On match, + /// receives the unquoted inner command string (suitable for recursive + /// parsing) and the method returns true. + /// + /// + /// We scan the segment's tokens directly (rather than running through + /// the full clause parser first) so the wrapper is consumed cleanly — + /// the outer clause never appears in ParsedCommand.Clauses. The + /// scan looks for: a Word verb of bash or sh, followed by + /// any combination of flag tokens, then a -c Word flag, then an + /// adjacent QuotedString token whose value becomes the inner command. + /// + private static bool TryDetectBashCWrapper(Segment segment, string source, out string? innerCommand) + { + innerCommand = null; + + if (segment.Tokens.Count < 3) + { + return false; + } + + // First non-flag Word token must be `bash` or `sh`. + var t0 = segment.Tokens[0]; + if (t0.Kind != BashTokenKind.Word) + { + return false; + } + + if (!string.Equals(t0.Value, "bash", StringComparison.OrdinalIgnoreCase) + && !string.Equals(t0.Value, "sh", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + // Scan from index 1 for `-c` followed immediately by a QuotedString. + for (var i = 1; i < segment.Tokens.Count - 1; i++) + { + var t = segment.Tokens[i]; + if (t.Kind != BashTokenKind.Word) + { + return false; + } + + if (string.Equals(t.Value, "-c", StringComparison.Ordinal)) + { + var next = segment.Tokens[i + 1]; + if (next.Kind == BashTokenKind.QuotedString) + { + innerCommand = next.Value; + return true; + } + + // `-c` not followed by a quoted string → treat as a regular + // bash clause. (Caller falls through to the normal path.) + return false; + } + + if (!IsFlagWord(t)) + { + // First non-flag positional before reaching `-c` → not a + // bash-c wrapper. Treat as `bash script.sh ...` etc. + return false; + } + } + + return false; + } + // ---------------------------------------------------------------- token filtering private static List FilterSignificant(IReadOnlyList tokens) @@ -196,6 +557,23 @@ private sealed class Segment public List Tokens { get; init; } = new(); public bool FromSubshell { get; init; } + + /// + /// Paren-nesting depth of this segment. 0 = top-level; 1 = direct + /// child of one subshell; etc. PR 5 uses transitions in this value + /// across consecutive segments to push/pop the cd-attribution stack. + /// + public int SubshellDepth { get; init; } + + /// + /// Stack of subshell IDs from outermost to innermost. ID 0 is the + /// top-level command; each subsequent ( open assigns a fresh + /// monotonically-increasing ID. PR 5 uses divergence in this stack + /// across consecutive segments to detect exit-then-re-enter + /// boundaries (e.g. (a) && (b) where both segments + /// have SubshellDepth=1 but live in different subshells). + /// + public IReadOnlyList SubshellStack { get; init; } = Array.Empty(); } private static List SplitIntoSegments( @@ -204,10 +582,17 @@ private static List SplitIntoSegments( out string? error) { var segments = new List(); - var current = new Segment { PrecedingOperator = CompoundOperator.None }; + var subshellStack = new List { 0 }; // ID 0 is the top-level command. + var nextSubshellId = 1; + + var current = new Segment + { + PrecedingOperator = CompoundOperator.None, + SubshellDepth = 0, + SubshellStack = subshellStack.ToArray(), + }; var depth = 0; - var subshellDepthStack = new Stack(); for (var i = 0; i < tokens.Count; i++) { @@ -222,15 +607,19 @@ private static List SplitIntoSegments( if (current.Tokens.Count > 0) { segments.Add(current); - current = new Segment { PrecedingOperator = CompoundOperator.Sequence, FromSubshell = subshellDepthStack.Count > 0 }; - } - else - { - current = new Segment { PrecedingOperator = current.PrecedingOperator, FromSubshell = true }; } - subshellDepthStack.Push(depth); depth++; + subshellStack.Add(nextSubshellId++); + current = new Segment + { + PrecedingOperator = current.Tokens.Count > 0 + ? CompoundOperator.Sequence + : current.PrecedingOperator, + FromSubshell = true, + SubshellDepth = depth, + SubshellStack = subshellStack.ToArray(), + }; continue; } @@ -243,10 +632,7 @@ private static List SplitIntoSegments( } depth--; - if (subshellDepthStack.Count > 0) - { - subshellDepthStack.Pop(); - } + subshellStack.RemoveAt(subshellStack.Count - 1); if (current.Tokens.Count > 0) { @@ -256,7 +642,9 @@ private static List SplitIntoSegments( current = new Segment { PrecedingOperator = CompoundOperator.None, - FromSubshell = subshellDepthStack.Count > 0, + FromSubshell = depth > 0, + SubshellDepth = depth, + SubshellStack = subshellStack.ToArray(), }; continue; } @@ -277,7 +665,9 @@ private static List SplitIntoSegments( current = new Segment { PrecedingOperator = MapOperator(op), - FromSubshell = subshellDepthStack.Count > 0, + FromSubshell = depth > 0, + SubshellDepth = depth, + SubshellStack = subshellStack.ToArray(), }; continue; } @@ -335,7 +725,12 @@ public ClauseResult(IReadOnlyList clauses, string? error) public static ClauseResult Fail(string reason) => new(Array.Empty(), reason); } - private static ClauseResult ParseClauseSegment(Segment segment, string source, BashParserOptions options) + private static ClauseResult ParseClauseSegment( + Segment segment, string source, BashParserOptions options) + => ParseClauseSegment(segment, source, options, workingDirectoryUnknown: false); + + private static ClauseResult ParseClauseSegment( + Segment segment, string source, BashParserOptions options, bool workingDirectoryUnknown) { if (segment.Tokens.Count == 0) { @@ -432,6 +827,7 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source, B options, verb: new VerbChain(), consumedFlagValueIndices: consumedFlagValueIndices, + workingDirectoryUnknown: workingDirectoryUnknown, out var emptyArgs, out var emptyRedirects, out var redirectError); @@ -491,6 +887,7 @@ private static ClauseResult ParseClauseSegment(Segment segment, string source, B consumedFlagValueIndices: consumedFlagValueIndices, skipIndices: verbPositions, verbKeyForFlagValuePaths: verbTokens[0], + workingDirectoryUnknown: workingDirectoryUnknown, out var args, out var redirects, out var argError); @@ -554,6 +951,7 @@ private static void ExtractRedirectsAndArgs( BashParserOptions options, VerbChain verb, HashSet consumedFlagValueIndices, + bool workingDirectoryUnknown, out IReadOnlyList args, out IReadOnlyList redirects, out string? error) @@ -567,6 +965,7 @@ private static void ExtractRedirectsAndArgs( consumedFlagValueIndices, skipIndices: null, verbKeyForFlagValuePaths: verb.Tokens is null || verb.Tokens.Count == 0 ? null : verb.Tokens[0], + workingDirectoryUnknown: workingDirectoryUnknown, out args, out redirects, out error); @@ -581,6 +980,7 @@ private static void ExtractRedirectsAndArgs( HashSet consumedFlagValueIndices, HashSet? skipIndices, string? verbKeyForFlagValuePaths, + bool workingDirectoryUnknown, out IReadOnlyList args, out IReadOnlyList redirects, out string? error) @@ -628,7 +1028,7 @@ private static void ExtractRedirectsAndArgs( return; } - BuildRedirect(dir, target, source, options, redirectList); + BuildRedirect(dir, target, source, options, redirectList, workingDirectoryUnknown); i += 2; continue; } @@ -695,7 +1095,7 @@ private static void ExtractRedirectsAndArgs( // so we don't apply LooksLikePath here). var valueIsPath = verbKeyForFlagValuePaths is not null && BashPerVerbRules.ValueOfFlagIsPath(verbKeyForFlagValuePaths, flagPart); - var (vKind, vResolved, vIsPath) = BashResolver.Resolve(valuePart, valueIsPath, options); + var (vKind, vResolved, vIsPath) = BashResolver.Resolve(valuePart, valueIsPath, options, workingDirectoryUnknown); argList.Add(new Arg { Raw = valuePart, @@ -757,7 +1157,7 @@ private static void ExtractRedirectsAndArgs( positionalIndex++; } - var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options); + var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options, workingDirectoryUnknown); argList.Add(new Arg { Raw = sourceRaw, @@ -790,7 +1190,7 @@ private static void ExtractRedirectsAndArgs( positionalIndex++; } - var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options); + var (kind, resolved, isPath) = BashResolver.Resolve(t.Value, treatAsPath, options, workingDirectoryUnknown); argList.Add(new Arg { Raw = sourceRaw, @@ -839,7 +1239,8 @@ private static void BuildRedirect( BashToken target, string source, BashParserOptions options, - List redirectList) + List redirectList, + bool workingDirectoryUnknown) { if (target.Kind == BashTokenKind.OpaqueSubstitution) { @@ -860,7 +1261,7 @@ private static void BuildRedirect( // locked interpretation #3: a glob target stays IsPath=true with // Kind=Glob; an env-var target becomes DynamicSkip; a literal // resolves against WorkingDirectory. - var (kind, resolved, _) = BashResolver.Resolve(target.Value, treatAsPath: true, options); + var (kind, resolved, _) = BashResolver.Resolve(target.Value, treatAsPath: true, options, workingDirectoryUnknown); bool isDynamic; string redirectTarget; diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/CdAttributionContext.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/CdAttributionContext.cs new file mode 100644 index 0000000..b44729c --- /dev/null +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/CdAttributionContext.cs @@ -0,0 +1,130 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Aaron Stannard +// +// ----------------------------------------------------------------------- +using System.Collections.Generic; + +namespace ShellSyntaxTree.Internal.Bash.Parsing; + +/// +/// Parser-internal mutable state that tracks the "attributed cwd" for the +/// current compound while building clauses. The output AST stays immutable; +/// this object just drives: +/// +/// which subsequent clauses receive a synthetic +/// arg (SPEC §9); +/// which the +/// resolver runs against for clauses that follow a literal +/// cd/chdir; +/// subshell isolation — push/pop via +/// and so a cd inside a +/// subshell doesn't leak out (SPEC §9 rule 4 / §10). +/// +/// Only cd and chdir propagate attribution per locked +/// interpretation #5; pushd/popd/push-location/ +/// set-location are parsed as CwdVerbs but explicitly skipped here. +/// +internal sealed class CdAttributionContext +{ + /// + /// Snapshot saved during ; restored on + /// . Inner state is mutated freely while + /// the subshell parses; on exit, the outer state is reinstated. + /// + private readonly Stack _frames = new(); + + /// + /// The resolved working directory inherited from the most recent + /// literal cd/chdir in the current compound. Null when + /// no attribution is active or when the cd target was dynamic. + /// + public string? ResolvedCwd { get; private set; } + + /// + /// True when the most recent cd/chdir target resolved to + /// (locked interpretation #6). The + /// synthetic attribution arg appended to subsequent clauses uses + /// Kind=DynamicSkip, IsPath=false, Resolved=null; relative path + /// args in those clauses are not re-resolved. + /// + public bool IsDynamic { get; private set; } + + /// + /// True when a previous cd/chdir set attribution (either + /// literal or dynamic). Drives whether subsequent clauses receive the + /// synthetic IsCwdAttribution arg. + /// + public bool HasAttribution => ResolvedCwd is not null || IsDynamic; + + /// + /// Record a literal cd attribution. + /// must be the resolver's normalized absolute path for the cd's first + /// non-flag positional. Replaces any previous attribution per SPEC §9 + /// rule 3. + /// + public void SetLiteralAttribution(string resolvedTarget) + { + ResolvedCwd = resolvedTarget; + IsDynamic = false; + } + + /// + /// Record that the most recent cd target was dynamic (locked + /// interpretation #6). Subsequent clauses get a synthetic DynamicSkip + /// attribution arg; their relative paths are not re-resolved. + /// + public void SetDynamicAttribution() + { + ResolvedCwd = null; + IsDynamic = true; + } + + /// + /// Push the current attribution state onto an internal stack so the + /// inner subshell can mutate freely. The pushed state mirrors current + /// values — a subshell inherits outer attribution (so e.g. + /// cd /a && (cmd1) still attributes cmd1 to /a) but + /// changes inside the subshell stay isolated. + /// + public void PushForSubshell() + { + _frames.Push(new Frame(ResolvedCwd, IsDynamic)); + } + + /// + /// Restore the most recently pushed attribution state. Called when the + /// parser leaves a subshell. Any cd attribution that the subshell + /// applied is discarded — only the outer state survives (SPEC §9 + /// rule 4 / §10 "subshell isolation"). + /// + public void PopForSubshell() + { + if (_frames.Count == 0) + { + // Defensive — caller bug. Reset to a no-attribution state + // rather than throw; the parser is in safe-fail territory if + // depth tracking diverges, but the output AST stays well-formed. + ResolvedCwd = null; + IsDynamic = false; + return; + } + + var frame = _frames.Pop(); + ResolvedCwd = frame.ResolvedCwd; + IsDynamic = frame.IsDynamic; + } + + private readonly struct Frame + { + public Frame(string? resolvedCwd, bool isDynamic) + { + ResolvedCwd = resolvedCwd; + IsDynamic = isDynamic; + } + + public string? ResolvedCwd { get; } + + public bool IsDynamic { get; } + } +} diff --git a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs index b5edca2..f6a81ae 100644 --- a/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs +++ b/src/ShellSyntaxTree/Internal/Resolving/BashResolver.cs @@ -68,7 +68,23 @@ internal static class BashResolver /// . /// internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( - string raw, bool treatAsPath, BashParserOptions options) + string raw, bool treatAsPath, BashParserOptions options) => + Resolve(raw, treatAsPath, options, workingDirectoryUnknown: false); + + /// + /// Internal extended-resolver entry point. PR 5 adds the + /// flag for the + /// dynamic-cd-attribution case (locked interpretation #6): when the + /// preceding clause did cd $VAR, we statically don't know the + /// working directory of subsequent clauses, so relative-path args + /// resolve to DynamicSkip instead of falling back to the + /// daemon cwd. + /// + internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( + string raw, + bool treatAsPath, + BashParserOptions options, + bool workingDirectoryUnknown) { if (raw is null) { @@ -171,7 +187,7 @@ internal static (ArgKind Kind, string? Resolved, bool IsPath) Resolve( } // Path slot: try to normalize to an absolute path. - var resolved = TryResolveAbsolutePath(working, options); + var resolved = TryResolveAbsolutePath(working, options, workingDirectoryUnknown); if (resolved is null) { // Resolution failed (IOException / ArgumentException / format) — @@ -444,7 +460,8 @@ private static string JoinPath(string baseDir, string sub) /// platform-aware and would produce `D:\foo` for `/foo` on Windows, /// which is wrong for our bash-parsing semantics. /// - private static string? TryResolveAbsolutePath(string token, BashParserOptions options) + private static string? TryResolveAbsolutePath( + string token, BashParserOptions options, bool workingDirectoryUnknown) { if (string.IsNullOrEmpty(token)) { @@ -458,6 +475,15 @@ private static string JoinPath(string baseDir, string sub) { combined = NormalizeToForwardSlashes(token); } + else if (workingDirectoryUnknown) + { + // Locked interpretation #6: caller (cd-attribution stage) + // signaled that the working directory of subsequent clauses + // is statically unknown. Don't fall back to the daemon cwd + // — surface as DynamicSkip so the consumer routes to + // safe-fail. + return null; + } else { var wd = GetWorkingDirectory(options); diff --git a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs index c598719..6b60a1f 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs +++ b/tests/ShellSyntaxTree.Tests/Corpus/CorpusRunnerTests.cs @@ -171,6 +171,10 @@ private static void AssertArgEqual(ExpectedArg expected, Arg actual, string diff Assert.True(expected.Raw == actual.Raw, diffPrefix + $"Raw mismatch. expected='{expected.Raw}', actual='{actual.Raw}'"); Assert.True(expected.Kind == actual.Kind, diffPrefix + $"Kind mismatch. expected={expected.Kind}, actual={actual.Kind}"); Assert.True(expected.IsPath == actual.IsPath, diffPrefix + $"IsPath mismatch. expected={expected.IsPath}, actual={actual.IsPath}"); + Assert.True( + expected.IsCwdAttribution == actual.IsCwdAttribution, + diffPrefix + $"IsCwdAttribution mismatch. expected={expected.IsCwdAttribution}, actual={actual.IsCwdAttribution}"); + // isFlag is computed; assert when present so corpus can document it. if (expected.IsFlag.HasValue) { @@ -275,6 +279,14 @@ public sealed record ExpectedArg /// "__NULL__" to assert that Resolved is null. /// public string? Resolved { get; init; } + + /// + /// Expected . Omit to assert + /// false (the default for normal user-emitted args); set to + /// true to assert the synthetic cd-attribution arg that PR 5 + /// appends to subsequent clauses (SPEC §9 / locked interpretation #6). + /// + public bool IsCwdAttribution { get; init; } } public sealed record ExpectedRedirect diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/100_bash_c_nested_depth_3.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/100_bash_c_nested_depth_3.json new file mode 100644 index 0000000..7240c59 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/100_bash_c_nested_depth_3.json @@ -0,0 +1,20 @@ +{ + "name": "bash -c nested depth 3: three wrappers consumed", + "input": "bash -c \"bash -c \\\"bash -c \\\\\\\"echo deep\\\\\\\"\\\"\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "deep", "kind": "Literal", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "Three nested bash -c wrappers all consumed; innermost echo surfaces flat. Depth 3 is under the cap of 5; the BashCommandParserTests cover the depth-6 overflow case." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json index 15d6dad..7eea549 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/25_cd_then_ls.json @@ -17,12 +17,14 @@ { "operator": "AndIf", "verb": ["ls"], - "args": [], + "args": [ + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp", "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isBashCWrapped": false } ] }, - "notes": "PR 4: cd target resolves. PR 5 will append the synthetic /tmp arg to clause 1." + "notes": "PR 5: ls inherits /tmp via the synthetic IsCwdAttribution arg." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json index c00a2f3..307f6a7 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/28_cd_status_push.json @@ -17,7 +17,9 @@ { "operator": "AndIf", "verb": ["git", "status"], - "args": [], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isBashCWrapped": false @@ -25,12 +27,14 @@ { "operator": "AndIf", "verb": ["git", "push"], - "args": [], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isBashCWrapped": false } ] }, - "notes": "PR 4: cd target resolves. PR 5 will append /repo to subsequent clauses." + "notes": "PR 5: both git status and git push inherit /repo as attribution." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json index 8b4e79a..e465442 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/34_cd_then_rm.json @@ -18,7 +18,8 @@ "operator": "AndIf", "verb": ["rm"], "args": [ - { "raw": "temp.log", "kind": "Literal", "isPath": true, "resolved": "/work/temp.log" } + { "raw": "temp.log", "kind": "Literal", "isPath": true, "resolved": "/tmp/temp.log" }, + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp", "isCwdAttribution": true } ], "redirects": [], "isSubshell": false, @@ -26,5 +27,5 @@ } ] }, - "notes": "PR 4: temp.log is a relative path arg for rm (FileVerb) resolving against the test cwd /work. PR 5 will swap that to /tmp via cd-attribution." + "notes": "PR 5: temp.log resolves against the attributed cwd /tmp (not the test cwd /work). Synthetic attribution arg follows in source-order at end." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json index a88f4b6..778481b 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/35_cd_pipe_grep.json @@ -17,7 +17,9 @@ { "operator": "AndIf", "verb": ["cmd"], - "args": [], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isBashCWrapped": false @@ -26,7 +28,8 @@ "operator": "Pipe", "verb": ["grep"], "args": [ - { "raw": "foo", "kind": "Literal", "isPath": false } + { "raw": "foo", "kind": "Literal", "isPath": false }, + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } ], "redirects": [], "isSubshell": false, @@ -34,5 +37,5 @@ } ] }, - "notes": "PR 4: cd target /repo resolves; grep i=0 is the pattern (IsPath=false)." + "notes": "PR 5: every clause after `cd /repo` (across both && and |) inherits the /repo attribution." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json index 4df68b7..47e7bf1 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/52_dynamic_skip_cd_var.json @@ -17,12 +17,14 @@ { "operator": "AndIf", "verb": ["cmd"], - "args": [], + "args": [ + { "raw": "", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], "redirects": [], "isSubshell": false, "isBashCWrapped": false } ] }, - "notes": "cd target is an unresolved env var → DynamicSkip. PR 5 will append the matching attribution arg." + "notes": "Locked interpretation #6: cd target is an unresolved env var, so subsequent clauses get a synthetic DynamicSkip attribution arg (cwd statically unknown)." } diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/71_cd_attribution_relative_cat.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/71_cd_attribution_relative_cat.json new file mode 100644 index 0000000..797563d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/71_cd_attribution_relative_cat.json @@ -0,0 +1,31 @@ +{ + "name": "cd-attribution: cd /target && cat file.txt", + "input": "cd /target && cat file.txt", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cat"], + "args": [ + { "raw": "file.txt", "kind": "Literal", "isPath": true, "resolved": "/target/file.txt" }, + { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §9 example: file.txt resolves against /target via attribution." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/72_cd_three_subsequent_clauses.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/72_cd_three_subsequent_clauses.json new file mode 100644 index 0000000..112f055 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/72_cd_three_subsequent_clauses.json @@ -0,0 +1,50 @@ +{ + "name": "cd-attribution: cd /repo && a && b && c", + "input": "cd /repo && a && b && c", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["a"], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["b"], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["c"], + "args": [ + { "raw": "/repo", "kind": "Literal", "isPath": true, "resolved": "/repo", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Attribution propagates to every subsequent clause in the compound." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/73_cd_replaces_attribution.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/73_cd_replaces_attribution.json new file mode 100644 index 0000000..1642105 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/73_cd_replaces_attribution.json @@ -0,0 +1,51 @@ +{ + "name": "cd-attribution: cd /a && cmd1 && cd /b && cmd2 (cd replaces)", + "input": "cd /a && cmd1 && cd /b && cmd2", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd1"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cd"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b" }, + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §9 rule 3: second cd replaces attribution; cd /b sees /a (rule 2) but cmd2 sees /b." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json new file mode 100644 index 0000000..117cd5d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/74_cd_relative_inside_attribution.json @@ -0,0 +1,31 @@ +{ + "name": "cd-attribution: cd /a && cd sub (relative cd resolves against attributed cwd)", + "input": "cd /a && cd sub", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cd"], + "args": [ + { "raw": "sub", "kind": "Literal", "isPath": true, "resolved": "/a/sub" }, + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Relative cd target resolves against the active attribution; PR 5 swaps in WorkingDirectory=/a for the second cd." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/75_cd_dynamic_var.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/75_cd_dynamic_var.json new file mode 100644 index 0000000..a689683 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/75_cd_dynamic_var.json @@ -0,0 +1,32 @@ +{ + "name": "cd-attribution: cd $REPO && rm -rf node_modules (dynamic)", + "input": "cd $REPO && rm -rf node_modules", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "$REPO", "kind": "DynamicSkip", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["rm"], + "args": [ + { "raw": "-rf", "kind": "Literal", "isPath": false, "isFlag": true }, + { "raw": "node_modules", "kind": "DynamicSkip", "isPath": false }, + { "raw": "", "kind": "DynamicSkip", "isPath": false, "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Locked interpretation #6: cd target is dynamic → node_modules can't safely resolve (DynamicSkip), and the synthetic attribution arg signals 'cwd unknown'." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json new file mode 100644 index 0000000..3af3384 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/76_cd_dash_treated_dynamic.json @@ -0,0 +1,28 @@ +{ + "name": "cd-attribution: cd - && cmd (cd dash → dynamic)", + "input": "cd - && cmd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "-", "kind": "Literal", "isPath": false, "isFlag": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "`cd -` swaps to previous dir; we don't track that, so cd has only the dash flag (no non-flag positional). Attribution stays unset; cmd receives no synthetic arg." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json new file mode 100644 index 0000000..4a3c0ad --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/77_pushd_does_not_propagate.json @@ -0,0 +1,28 @@ +{ + "name": "cd-attribution: pushd /target && cmd (pushd does not propagate)", + "input": "pushd /target && cmd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["pushd"], + "args": [ + { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Locked interpretation #5: pushd parses as a CwdVerb (target IsPath=true) but doesn't propagate attribution. cmd has no synthetic arg." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json new file mode 100644 index 0000000..e854caa --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/78_popd_does_not_propagate.json @@ -0,0 +1,26 @@ +{ + "name": "cd-attribution: popd && cmd (popd does not propagate)", + "input": "popd && cmd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["popd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Locked interpretation #5: popd parses but doesn't propagate. cmd has no attribution arg." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/79_chdir_propagates_like_cd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/79_chdir_propagates_like_cd.json new file mode 100644 index 0000000..b89922f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/79_chdir_propagates_like_cd.json @@ -0,0 +1,30 @@ +{ + "name": "cd-attribution: chdir /target && cmd (chdir also propagates)", + "input": "chdir /target && cmd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["chdir"], + "args": [ + { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [ + { "raw": "/target", "kind": "Literal", "isPath": true, "resolved": "/target", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Locked interpretation #5: chdir is the other propagating CwdVerb (forward-compat with PowerShell-style invocation)." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/80_cd_then_mkdir_relative.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/80_cd_then_mkdir_relative.json new file mode 100644 index 0000000..c65856f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/80_cd_then_mkdir_relative.json @@ -0,0 +1,31 @@ +{ + "name": "cd-attribution: cd /tmp && mkdir foo (mkdir resolves against /tmp)", + "input": "cd /tmp && mkdir foo", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["mkdir"], + "args": [ + { "raw": "foo", "kind": "Literal", "isPath": true, "resolved": "/tmp/foo" }, + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "mkdir's relative `foo` resolves to /tmp/foo via the attributed cwd." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/81_subshell_simple.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/81_subshell_simple.json new file mode 100644 index 0000000..0fbfebb --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/81_subshell_simple.json @@ -0,0 +1,26 @@ +{ + "name": "Subshell: (cmd1 && cmd2)", + "input": "(cmd1 && cmd2)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cmd1"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §10: clauses inside parens carry IsSubshell=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/82_subshell_isolates_cd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/82_subshell_isolates_cd.json new file mode 100644 index 0000000..9e120e1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/82_subshell_isolates_cd.json @@ -0,0 +1,51 @@ +{ + "name": "Subshell isolation: cd /a && (cd /b && cmd1) && cmd2", + "input": "cd /a && (cd /b && cmd1) && cmd2", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cd"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b" }, + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd1"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "SPEC §10 example: subshell's cd /b doesn't leak; cmd2 inherits /a from the outer compound." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json new file mode 100644 index 0000000..c194fb0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/83_subshell_with_pipe.json @@ -0,0 +1,26 @@ +{ + "name": "Subshell with pipe: (a | b)", + "input": "(a | b)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["a"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "Pipe", + "verb": ["b"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + } + ] + }, + "notes": "Pipe inside subshell — both clauses carry IsSubshell=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/84_subshell_nested.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/84_subshell_nested.json new file mode 100644 index 0000000..0abdba0 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/84_subshell_nested.json @@ -0,0 +1,20 @@ +{ + "name": "Nested subshells: ((echo deep))", + "input": "((echo deep))", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "deep", "kind": "Literal", "isPath": false } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + } + ] + }, + "notes": "Two levels of parens; the inner clause is still IsSubshell=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/85_subshell_outer_compound_after.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/85_subshell_outer_compound_after.json new file mode 100644 index 0000000..72c0aa8 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/85_subshell_outer_compound_after.json @@ -0,0 +1,26 @@ +{ + "name": "Subshell followed by outer clause: (cmd1) && cmd2", + "input": "(cmd1) && cmd2", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cmd1"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "cmd2 follows the subshell exit; IsSubshell=false because it sits in the outer compound." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/86_subshell_outer_cd_inherited.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/86_subshell_outer_cd_inherited.json new file mode 100644 index 0000000..249b3ed --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/86_subshell_outer_cd_inherited.json @@ -0,0 +1,40 @@ +{ + "name": "Subshell inherits outer cd: cd /a && (cmd1) && cmd2", + "input": "cd /a && (cmd1) && cmd2", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd1"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Subshell INHERITS outer cd attribution (subshell sees outer state). Outer compound also keeps it after subshell exits." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/87_subshell_only_cd_inside.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/87_subshell_only_cd_inside.json new file mode 100644 index 0000000..4be6920 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/87_subshell_only_cd_inside.json @@ -0,0 +1,28 @@ +{ + "name": "Subshell isolation: (cd /tmp) && cmd (subshell cd doesn't leak)", + "input": "(cd /tmp) && cmd", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Subshell-local cd; on exit the outer compound has no attribution to apply." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/88_subshell_three_clauses.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/88_subshell_three_clauses.json new file mode 100644 index 0000000..b12132d --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/88_subshell_three_clauses.json @@ -0,0 +1,34 @@ +{ + "name": "Subshell three-clause: (a; b; c)", + "input": "(a; b; c)", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["a"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "Sequence", + "verb": ["b"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "Sequence", + "verb": ["c"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + } + ] + }, + "notes": "Subshell with sequence operators; all three clauses carry IsSubshell=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/89_subshell_first_then_outer.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/89_subshell_first_then_outer.json new file mode 100644 index 0000000..809c382 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/89_subshell_first_then_outer.json @@ -0,0 +1,34 @@ +{ + "name": "Subshell at start with two outer clauses: (a) && b && c", + "input": "(a) && b && c", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["a"], + "args": [], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["b"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["c"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Outer compound clauses after subshell exit are IsSubshell=false." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/90_subshell_inner_cd_attribution_only_inside.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/90_subshell_inner_cd_attribution_only_inside.json new file mode 100644 index 0000000..61ac3e7 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/90_subshell_inner_cd_attribution_only_inside.json @@ -0,0 +1,38 @@ +{ + "name": "Subshell with inner cd: (cd /b && cmd1) && cmd2 (no outer cd)", + "input": "(cd /b && cmd1) && cmd2", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b" } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd1"], + "args": [ + { "raw": "/b", "kind": "Literal", "isPath": true, "resolved": "/b", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": true, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["cmd2"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "Subshell-local cd /b; cmd1 inherits inside, cmd2 outside has no attribution (subshell isolated)." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/91_bash_c_simple.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/91_bash_c_simple.json new file mode 100644 index 0000000..17da9e1 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/91_bash_c_simple.json @@ -0,0 +1,20 @@ +{ + "name": "bash -c simple: bash -c \"echo hi\"", + "input": "bash -c \"echo hi\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "hi", "kind": "Literal", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "bash -c wrapper consumed; inner echo surfaces with IsBashCWrapped=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/92_bash_c_with_inner_cd.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/92_bash_c_with_inner_cd.json new file mode 100644 index 0000000..ac502f2 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/92_bash_c_with_inner_cd.json @@ -0,0 +1,30 @@ +{ + "name": "bash -c with inner cd attribution: bash -c \"cd /a && cmd\"", + "input": "bash -c \"cd /a && cmd\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + }, + { + "operator": "AndIf", + "verb": ["cmd"], + "args": [ + { "raw": "/a", "kind": "Literal", "isPath": true, "resolved": "/a", "isCwdAttribution": true } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "SPEC §10: bash -c body parses recursively; inner cd attribution works inside the wrapped context." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/93_sh_c_recurses.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/93_sh_c_recurses.json new file mode 100644 index 0000000..a6b44c6 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/93_sh_c_recurses.json @@ -0,0 +1,20 @@ +{ + "name": "sh -c recursion: sh -c \"ls /tmp\"", + "input": "sh -c \"ls /tmp\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["ls"], + "args": [ + { "raw": "/tmp", "kind": "Literal", "isPath": true, "resolved": "/tmp" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "sh -c is treated identically to bash -c (SPEC §10)." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/94_bash_c_no_quoted_body_stays_clause.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/94_bash_c_no_quoted_body_stays_clause.json new file mode 100644 index 0000000..b41009a --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/94_bash_c_no_quoted_body_stays_clause.json @@ -0,0 +1,20 @@ +{ + "name": "bash -c missing body: bash script.sh (no -c, not a wrapper)", + "input": "bash script.sh", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["bash"], + "args": [ + { "raw": "script.sh", "kind": "Literal", "isPath": true, "resolved": "/work/script.sh" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + } + ] + }, + "notes": "`bash script.sh` is not a wrapper invocation; it parses as a normal bash clause running a script." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json new file mode 100644 index 0000000..ad3a612 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/95_bash_c_inner_compound.json @@ -0,0 +1,34 @@ +{ + "name": "bash -c inner compound: bash -c \"a && b && c\"", + "input": "bash -c \"a && b && c\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["a"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + }, + { + "operator": "AndIf", + "verb": ["b"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + }, + { + "operator": "AndIf", + "verb": ["c"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "Inner compound surfaces as three IsBashCWrapped=true clauses." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/96_bash_c_nested_depth_2.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/96_bash_c_nested_depth_2.json new file mode 100644 index 0000000..caaace3 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/96_bash_c_nested_depth_2.json @@ -0,0 +1,20 @@ +{ + "name": "bash -c nested depth 2: bash -c \"bash -c \\\"echo hi\\\"\"", + "input": "bash -c \"bash -c \\\"echo hi\\\"\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["echo"], + "args": [ + { "raw": "hi", "kind": "Literal", "isPath": false } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "Two wrappers consumed; the innermost echo surfaces flat. Depth 2 well under the cap of 5." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/97_bash_c_with_sh_c_inside.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/97_bash_c_with_sh_c_inside.json new file mode 100644 index 0000000..e3e7678 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/97_bash_c_with_sh_c_inside.json @@ -0,0 +1,18 @@ +{ + "name": "bash -c with sh -c inside: bash -c \"sh -c \\\"pwd\\\"\"", + "input": "bash -c \"sh -c \\\"pwd\\\"\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["pwd"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "Wrapper kinds mix freely; both consumed, pwd surfaces with IsBashCWrapped=true." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/98_bash_c_outer_cd_does_not_propagate.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/98_bash_c_outer_cd_does_not_propagate.json new file mode 100644 index 0000000..2bb5a5c --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/98_bash_c_outer_cd_does_not_propagate.json @@ -0,0 +1,28 @@ +{ + "name": "bash -c does not inherit outer cd: cd /outer && bash -c \"ls\"", + "input": "cd /outer && bash -c \"ls\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["cd"], + "args": [ + { "raw": "/outer", "kind": "Literal", "isPath": true, "resolved": "/outer" } + ], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["ls"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "v0.1 decision: outer cd attribution does NOT leak into bash -c inner clauses (fresh shell semantics)." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/99_bash_c_after_outer_compound.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/99_bash_c_after_outer_compound.json new file mode 100644 index 0000000..d832171 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/99_bash_c_after_outer_compound.json @@ -0,0 +1,26 @@ +{ + "name": "bash -c surfaces operator from outer: a && bash -c \"b\"", + "input": "a && bash -c \"b\"", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["a"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": false + }, + { + "operator": "AndIf", + "verb": ["b"], + "args": [], + "redirects": [], + "isSubshell": false, + "isBashCWrapped": true + } + ] + }, + "notes": "Inner clause inherits the outer segment's operator (AndIf) since the bash -c wrapper is consumed." +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs index 98fa61d..d5ff527 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs @@ -447,26 +447,109 @@ public void Process_substitution_output_marks_outer_unparseable() Assert.Contains("process substitution", result.UnparseableReason!); } - // ---------------- bash -c framework ---------------- + // ---------------- bash -c recursion ---------------- [Fact] - public void Bash_c_treated_as_single_clause_in_pr3() + public void Bash_c_inner_compound_surfaces_inline_with_wrapper_flag() { - // PR 3: framework only — no recursion into the inner string. The - // outer clause is verb=[bash] with `-c` flag and a quoted-string - // arg. PR 5 will surface the inner clauses. + // PR 5: bash -c recursion. The outer bash -c clause is consumed and + // the inner command's clauses surface inline, each with + // IsBashCWrapped=true. The inner cd attributes only within the + // inner shell — outer attribution does not propagate in or out + // (v0.1 decision; bash -c spawns a fresh shell). var result = Parse("bash -c \"cd /a && cmd\""); Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + + Assert.Equal(new[] { "cd" }, result.Clauses[0].Verb.Tokens); + Assert.Equal("/a", result.Clauses[0].Args[0].Resolved); + Assert.True(result.Clauses[0].IsBashCWrapped); + + Assert.Equal(new[] { "cmd" }, result.Clauses[1].Verb.Tokens); + Assert.Equal(CompoundOperator.AndIf, result.Clauses[1].Operator); + Assert.True(result.Clauses[1].IsBashCWrapped); + + // The inner cmd inherits /a from the inner cd via attribution. + Assert.Single(result.Clauses[1].Args); + Assert.True(result.Clauses[1].Args[0].IsCwdAttribution); + Assert.Equal("/a", result.Clauses[1].Args[0].Resolved); + } + + [Fact] + public void Sh_c_recurses_same_as_bash_c() + { + var result = Parse("sh -c \"echo hi\""); + Assert.False(result.IsUnparseable); + var clause = Assert.Single(result.Clauses); + Assert.Equal(new[] { "echo" }, clause.Verb.Tokens); + Assert.True(clause.IsBashCWrapped); + } + + [Fact] + public void Bash_c_without_quoted_arg_stays_a_regular_bash_clause() + { + // Plain `bash script.sh` is *not* a wrapper — `-c` is missing or + // unfollowed by a quoted body. Parse as a normal bash clause. + var result = Parse("bash script.sh"); var clause = Assert.Single(result.Clauses); Assert.Equal(new[] { "bash" }, clause.Verb.Tokens); - Assert.Equal(2, clause.Args.Count); - Assert.Equal("-c", clause.Args[0].Raw); - Assert.True(clause.Args[0].IsFlag); - // The inner string is preserved with quotes in Raw. - Assert.Equal("\"cd /a && cmd\"", clause.Args[1].Raw); Assert.False(clause.IsBashCWrapped); } + [Fact] + public void Bash_c_recursion_depth_exceeded_marks_outer_unparseable() + { + // Build a 6-level deep bash -c chain. At each level we wrap the + // body in `bash -c "..."` and escape-quote the inner level. + // Depth 6 > cap 5 → outer ParsedCommand.IsUnparseable=true per + // locked interpretation #4. + var inner = "echo hi"; + for (var depth = 0; depth < 6; depth++) + { + // Double-quote escape: each level escapes the existing + // double-quotes in the inner body. Bash double-quote semantics: + // `\"` represents a literal `"` inside a double-quoted string. + var escaped = inner.Replace("\\", "\\\\").Replace("\"", "\\\""); + inner = "bash -c \"" + escaped + "\""; + } + + var result = Parse(inner); + Assert.True(result.IsUnparseable); + Assert.NotNull(result.UnparseableReason); + Assert.Contains("bash -c recursion", result.UnparseableReason!); + } + + [Fact] + public void Bash_c_nested_depth_2_parses() + { + // `bash -c "bash -c \"echo hi\""` — depth 2, well under the cap. + var result = Parse("bash -c \"bash -c \\\"echo hi\\\"\""); + Assert.False(result.IsUnparseable); + var clause = Assert.Single(result.Clauses); + Assert.Equal(new[] { "echo" }, clause.Verb.Tokens); + Assert.True(clause.IsBashCWrapped); + } + + [Fact] + public void Bash_c_with_outer_cd_does_not_propagate_into_inner_clauses() + { + // v0.1 decision: bash -c is a fresh shell. The outer `cd /outer` + // attribution is NOT injected into the inner clauses (per the PR 5 + // brief). The inner `ls` has no IsCwdAttribution arg from /outer. + var result = Parse("cd /outer && bash -c \"ls\""); + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + + // Outer cd. + Assert.Equal(new[] { "cd" }, result.Clauses[0].Verb.Tokens); + + // Inner ls (surfaced from bash -c). No attribution arg. + Assert.Equal(new[] { "ls" }, result.Clauses[1].Verb.Tokens); + Assert.True(result.Clauses[1].IsBashCWrapped); + Assert.Equal(CompoundOperator.AndIf, result.Clauses[1].Operator); + Assert.Empty(result.Clauses[1].Args); + } + // ---------------- Empty / whitespace ---------------- [Fact] @@ -486,22 +569,191 @@ public void Whitespace_only_input_returns_empty_clauses() Assert.False(result.IsUnparseable); } - // ---------------- Subshell framework ---------------- + // ---------------- Subshell ---------------- [Fact] - public void Subshell_inner_clauses_surface_inline() + public void Subshell_inner_clauses_carry_IsSubshell_true() { - // PR 3: subshell parens are recognized; inner clauses are - // surfaced inline with IsSubshell=false (PR 5 will set the flag - // and add cd-attribution semantics). + // PR 5: every clause parsed inside a `(...)` subshell carries + // IsSubshell=true so consumers can distinguish them from outer + // clauses (SPEC §10). var result = Parse("(cmd1 && cmd2)"); Assert.False(result.IsUnparseable); Assert.Equal(2, result.Clauses.Count); Assert.Equal(new[] { "cmd1" }, result.Clauses[0].Verb.Tokens); Assert.Equal(new[] { "cmd2" }, result.Clauses[1].Verb.Tokens); Assert.Equal(CompoundOperator.AndIf, result.Clauses[1].Operator); + Assert.True(result.Clauses[0].IsSubshell); + Assert.True(result.Clauses[1].IsSubshell); + } + + [Fact] + public void Subshell_isolates_inner_cd_from_outer_compound() + { + // SPEC §10 example: `cd /a && (cd /b && cmd1) && cmd2`. + // - Clause 0: cd /a, outer. + // - Clause 1: cd /b inside subshell — inherits /a attribution. + // - Clause 2: cmd1 inside subshell — inherits /b (closer cd). + // - Clause 3: cmd2 outside subshell — inherits /a (the subshell's + // /b doesn't leak out). + var result = Parse("cd /a && (cd /b && cmd1) && cmd2"); + Assert.False(result.IsUnparseable); + Assert.Equal(4, result.Clauses.Count); + + // cd /a + Assert.Equal(new[] { "cd" }, result.Clauses[0].Verb.Tokens); Assert.False(result.Clauses[0].IsSubshell); - Assert.False(result.Clauses[1].IsSubshell); + Assert.Equal("/a", result.Clauses[0].Args[0].Resolved); + + // cd /b inside subshell — inherits /a from outer attribution. + Assert.Equal(new[] { "cd" }, result.Clauses[1].Verb.Tokens); + Assert.True(result.Clauses[1].IsSubshell); + Assert.Equal("/b", result.Clauses[1].Args[0].Resolved); + // The /a attribution arg follows the user-emitted /b. + Assert.Equal(2, result.Clauses[1].Args.Count); + Assert.True(result.Clauses[1].Args[1].IsCwdAttribution); + Assert.Equal("/a", result.Clauses[1].Args[1].Resolved); + + // cmd1 inside subshell — sees /b. + Assert.Equal(new[] { "cmd1" }, result.Clauses[2].Verb.Tokens); + Assert.True(result.Clauses[2].IsSubshell); + Assert.Single(result.Clauses[2].Args); + Assert.True(result.Clauses[2].Args[0].IsCwdAttribution); + Assert.Equal("/b", result.Clauses[2].Args[0].Resolved); + + // cmd2 outside — sees /a, NOT /b. + Assert.Equal(new[] { "cmd2" }, result.Clauses[3].Verb.Tokens); + Assert.False(result.Clauses[3].IsSubshell); + Assert.Single(result.Clauses[3].Args); + Assert.True(result.Clauses[3].Args[0].IsCwdAttribution); + Assert.Equal("/a", result.Clauses[3].Args[0].Resolved); + } + + [Fact] + public void Sequential_cd_replaces_attribution() + { + // SPEC §9 rule 3: a second cd in the same compound replaces + // attribution for clauses after it. SPEC §9 rule 2 + §10 example: + // every clause after the first cd — including a second cd — gets + // the outer attribution arg appended; the second cd then *updates* + // the context for clauses that follow it. + var result = Parse("cd /a && cmd1 && cd /b && cmd2"); + Assert.False(result.IsUnparseable); + Assert.Equal(4, result.Clauses.Count); + + // cmd1 sees /a. + Assert.Equal("/a", result.Clauses[1].Args[0].Resolved); + Assert.True(result.Clauses[1].Args[0].IsCwdAttribution); + + // cd /b — receives /a attribution (rule 2) before becoming the new + // source (rule 3). Args = [/b, /a-attribution]. + Assert.Equal(new[] { "cd" }, result.Clauses[2].Verb.Tokens); + Assert.Equal(2, result.Clauses[2].Args.Count); + Assert.Equal("/b", result.Clauses[2].Args[0].Resolved); + Assert.False(result.Clauses[2].Args[0].IsCwdAttribution); + Assert.True(result.Clauses[2].Args[1].IsCwdAttribution); + Assert.Equal("/a", result.Clauses[2].Args[1].Resolved); + + // cmd2 sees /b (rule 3 — replaced). + Assert.Single(result.Clauses[3].Args); + Assert.Equal("/b", result.Clauses[3].Args[0].Resolved); + Assert.True(result.Clauses[3].Args[0].IsCwdAttribution); + } + + [Fact] + public void Cd_relative_path_args_resolve_under_attributed_cwd() + { + // SPEC §9 example: `cd /target && cat file.txt` → file.txt resolves + // to /target/file.txt, not to the daemon cwd's file.txt. + var result = Parse("cd /target && cat file.txt"); + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + + var cat = result.Clauses[1]; + Assert.Equal(new[] { "cat" }, cat.Verb.Tokens); + Assert.Equal(2, cat.Args.Count); + + // file.txt resolves against /target, not /work. + Assert.Equal("file.txt", cat.Args[0].Raw); + Assert.Equal("/target/file.txt", cat.Args[0].Resolved); + + // Trailing synthetic attribution arg. + Assert.True(cat.Args[1].IsCwdAttribution); + Assert.Equal("/target", cat.Args[1].Resolved); + } + + [Fact] + public void Pushd_parses_as_cwd_verb_but_does_not_propagate() + { + // Locked interpretation #5: only cd/chdir propagate attribution. + // pushd parses as a CwdVerb (its first positional is path-classified) + // but the next clause receives NO synthetic attribution arg. + var result = Parse("pushd /target && cmd"); + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + Assert.Equal(new[] { "pushd" }, result.Clauses[0].Verb.Tokens); + Assert.Equal("/target", result.Clauses[0].Args[0].Resolved); + Assert.True(result.Clauses[0].Args[0].IsPath); + + // The cmd clause has no synthetic attribution arg. + Assert.Empty(result.Clauses[1].Args); + } + + [Fact] + public void Two_sibling_subshells_track_independent_attribution() + { + // `(cd /a && cmd1) && (cd /b && cmd2)` — each subshell has its own + // attribution state; neither leaks to the other and the outer + // compound's attribution stays unset throughout. + var result = Parse("(cd /a && cmd1) && (cd /b && cmd2)"); + Assert.False(result.IsUnparseable); + Assert.Equal(4, result.Clauses.Count); + + // First subshell: cd /a → cmd1 (sees /a). + Assert.Equal(new[] { "cd" }, result.Clauses[0].Verb.Tokens); + Assert.True(result.Clauses[0].IsSubshell); + + Assert.Equal(new[] { "cmd1" }, result.Clauses[1].Verb.Tokens); + Assert.True(result.Clauses[1].IsSubshell); + Assert.Single(result.Clauses[1].Args); + Assert.True(result.Clauses[1].Args[0].IsCwdAttribution); + Assert.Equal("/a", result.Clauses[1].Args[0].Resolved); + + // Second subshell: cd /b → cmd2 (sees /b, NOT /a). + Assert.Equal(new[] { "cd" }, result.Clauses[2].Verb.Tokens); + Assert.True(result.Clauses[2].IsSubshell); + // cd /b shouldn't have a /a attribution (separate subshell). + Assert.Single(result.Clauses[2].Args); + + Assert.Equal(new[] { "cmd2" }, result.Clauses[3].Verb.Tokens); + Assert.True(result.Clauses[3].IsSubshell); + Assert.Single(result.Clauses[3].Args); + Assert.True(result.Clauses[3].Args[0].IsCwdAttribution); + Assert.Equal("/b", result.Clauses[3].Args[0].Resolved); + } + + [Fact] + public void Cd_dynamic_target_marks_subsequent_relative_paths_as_dynamic_skip() + { + // Locked interpretation #6: `cd $REPO && rm file.txt` — the cwd is + // statically unknown, so file.txt cannot be safely resolved. + var result = Parse("cd $REPO && rm file.txt"); + Assert.False(result.IsUnparseable); + Assert.Equal(2, result.Clauses.Count); + + // cd target is DynamicSkip. + Assert.Equal(ArgKind.DynamicSkip, result.Clauses[0].Args[0].Kind); + + // rm's file.txt is a relative path → DynamicSkip with no resolution. + var rm = result.Clauses[1]; + Assert.Equal(2, rm.Args.Count); + Assert.Equal("file.txt", rm.Args[0].Raw); + Assert.Equal(ArgKind.DynamicSkip, rm.Args[0].Kind); + Assert.Null(rm.Args[0].Resolved); + + // Synthetic attribution arg is the DynamicSkip flavor. + Assert.True(rm.Args[1].IsCwdAttribution); + Assert.Equal(ArgKind.DynamicSkip, rm.Args[1].Kind); } // ---------------- Quoted args round-trip ----------------