From 318f9a3834436e702751bcc02499be9e03ef5ced Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 4 Aug 2026 13:26:36 -0500 Subject: [PATCH] Preserve path-shaped command operands --- IMPLEMENTATION_PLAN.md | 4 ++ SPEC.POWERSHELL.md | 17 +++-- SPEC.md | 25 +++++-- .../.openspec.yaml | 2 + .../preserve-path-shaped-operands/design.md | 71 +++++++++++++++++++ .../preserve-path-shaped-operands/proposal.md | 27 +++++++ .../specs/path-shaped-operands/spec.md | 59 +++++++++++++++ .../preserve-path-shaped-operands/tasks.md | 20 ++++++ .../Bash/Parsing/BashCommandParser.cs | 6 +- .../Internal/Bash/Verbs/BashVerbs.cs | 13 ++-- .../Pwsh/Parsing/PwshCommandParser.cs | 5 +- .../Internal/Pwsh/Verbs/PwshVerbs.cs | 3 +- .../150_git_diff_lowercase_shell_file.json | 26 +++++++ .../151_kubectl_apply_deployment_yaml.json | 26 +++++++ .../powershell/038_native_python_script.json | 12 +++- .../219_native_kubectl_apply_yaml.json | 26 +++++++ .../Parsing/BashCommandParserTests.cs | 68 ++++++++++++++++++ .../Parsing/PwshCommandParserTests.cs | 21 ++++++ tools/PwshCorpusTool/CorpusManifest.cs | 4 ++ 19 files changed, 411 insertions(+), 24 deletions(-) create mode 100644 openspec/changes/preserve-path-shaped-operands/.openspec.yaml create mode 100644 openspec/changes/preserve-path-shaped-operands/design.md create mode 100644 openspec/changes/preserve-path-shaped-operands/proposal.md create mode 100644 openspec/changes/preserve-path-shaped-operands/specs/path-shaped-operands/spec.md create mode 100644 openspec/changes/preserve-path-shaped-operands/tasks.md create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/150_git_diff_lowercase_shell_file.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/bash/151_kubectl_apply_deployment_yaml.json create mode 100644 tests/ShellSyntaxTree.Tests/Corpus/powershell/219_native_kubectl_apply_yaml.json diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md index fe6f32e..a91fef6 100644 --- a/IMPLEMENTATION_PLAN.md +++ b/IMPLEMENTATION_PLAN.md @@ -50,6 +50,10 @@ priorities. ### Completed maintenance +- [x] **Issue #64 — path-shaped operands after native verb chains.** + Stop the Bash and PowerShell native greedy passes before a token that + matches the shared path-shape rules. Preserve that token as a resolved + argument without a command dictionary or a public API change. - [x] **Issue #52 — hyphenated PowerShell parameters/native options.** Preserve internal hyphens, apply bash-compatible native `--flag=value` splitting and path classification, keep colon binding diff --git a/SPEC.POWERSHELL.md b/SPEC.POWERSHELL.md index c70547e..f2f4737 100644 --- a/SPEC.POWERSHELL.md +++ b/SPEC.POWERSHELL.md @@ -393,14 +393,21 @@ cmdlets, so the bash greedy walk does not apply to them. When the first token is neither cmdlet-shaped nor a known alias (`git`, `dotnet`, `npm`, `kubectl`, `python`, ...) it is a **native command**. -Native commands reuse the bash greedy verb-chain walk (`SPEC.md` §6.1): -append the first token, then walk consecutive verb-like Word tokens, -transparently consuming flag-with-value pairs, stopping at the first -non-verb-like token, flag, operator, quoted string, or opaque token. The -verb-like predicate is the bash predicate **unchanged** (`SPEC.md` §6.1: +Native commands reuse the bash greedy verb-chain walk (`SPEC.md` §6.1). +The parser appends the first token and then walks consecutive verb-like Word +tokens. The walk transparently consumes flag-with-value pairs. It stops at a +path-shaped token, non-verb-like token, flag, operator, quoted string, or +opaque token. + +The path-shape test uses `BashResolver.LooksLikePath`. Both native parsers +therefore share one boundary. The verb-like predicate is the bash predicate +**unchanged** (`SPEC.md` §6.1: `Kind == Word`, length `[1, 64]`, first char ASCII lowercase `[a-z]`, remaining chars `[a-z0-9._-]`). +The path-shape boundary does not apply to the first native command token. +For example, `deploy.sh status` has verb tokens `deploy.sh` and `status`. + Keeping the predicate **case-sensitive** — not relaxing it to accept an uppercase first char — is deliberate. The leading-lowercase rule is the only signal that stops the greedy walk at a capitalized identifier diff --git a/SPEC.md b/SPEC.md index ff6c624..f68b38e 100644 --- a/SPEC.md +++ b/SPEC.md @@ -394,7 +394,8 @@ verb_chain := verb_like_word (FW_pair? verb_like_word)* // greedy walk per §6.1; FW_pair is a // flag-with-value pair owned by word_0 // (transparent to the walk); stops at - // the first non-verb-like token. For + // the first path-shaped or non-verb-like + // token. For // word_0 ∈ FileVerbs, exactly 1 token. arg := word | flag | quoted_string flag := "-" letter+ | "--" word @@ -545,9 +546,9 @@ These are **data**, not logic. Implement as `static readonly` collections. ### 6.1 Verb-chain extraction (greedy heuristic) Per issue #27 (locked in v0.1.4-alpha), the parser does not consult a -static arity table. Instead, it walks consecutive verb-like Word tokens -from the start of the clause and stops at the first token that doesn't -look like a subcommand. This naturally scales to unknown CLIs +static arity table. It walks consecutive verb-like Word tokens from the +clause start. The walk stops before a path-shaped or non-verb-like token. +This rule naturally scales to unknown CLIs (`freshdesk ticket list`, `kubectl get pods`, `dotnet ef migrations add`) without curated table entries. @@ -568,13 +569,17 @@ paths (`/`, `\`, `~`), env-var refs (`$VAR`), URLs (`://`), globs (`* ? [`), and user-named identifiers (uppercase first char like `InitialCreate`). +The walk also rejects a token that matches the §8 path-shape heuristic. +This rule applies even when the lexical predicate accepts the token. + #### Walk algorithm For a clause whose first token is a Word `firstVerb`: 1. Append `firstVerb` to the verb chain (it does not need to satisfy `IsVerbLikeToken` — bare commands like `Curl` or `_init` are still - commands). + commands). Do not apply the path-shape boundary at command position. + A command such as `deploy.sh` or `./deploy.sh` remains the first verb. 2. Iterate the remaining tokens in order. For each token `t`: - If `t.Kind != Word`: **stop**. - If `t` is a flag (`IsFlagWord`): @@ -585,6 +590,8 @@ For a clause whose first token is a Word `firstVerb`: and continue walking. - Otherwise: **stop**. - If `firstVerb ∈ FileVerbs`: **stop** (1-token carveout — see below). + - If `BashResolver.LooksLikePath(t.Value)`: **stop**. The argument pass + uses the same classifier and preserves the token as a path argument. - If `!IsVerbLikeToken(t)`: **stop**. - Otherwise: append `t.Value` to the verb chain and continue. @@ -617,6 +624,10 @@ on for zone-gate evaluation. | `kubectl get pods my-pod` | `[kubectl, get, pods, my-pod]` | `[]` | | `aws s3 cp src dst` | `[aws, s3, cp, src, dst]` | `[]` (bare-word path args over-extract) | | `dotnet ef migrations add InitialCreate` | `[dotnet, ef, migrations, add]` | `[InitialCreate]` (stops at uppercase) | +| `deploy.sh status` | `[deploy.sh, status]` | `[]` (command position wins) | +| `git diff install-skills.sh` | `[git, diff]` | `[install-skills.sh]` (path-shaped operand) | +| `kubectl apply deployment.yaml` | `[kubectl, apply]` | `[deployment.yaml]` (path-shaped operand) | +| `tool plugin.sh list` | `[tool]` | `[plugin.sh, list]` (path evidence wins) | | `cat /etc/passwd` | `[cat]` | `[/etc/passwd]` (FileVerb carveout) | | `cat README` | `[cat]` | `[README]` (FileVerb carveout preserves IsPath) | | `ls -la /tmp` | `[ls]` | `[-la, /tmp]` (FileVerb carveout) | @@ -655,6 +666,10 @@ False-negative (re-prompt) is recoverable. False-positive (silent destructive grant) is not. Narrow-by-default favors the recoverable failure mode. +The path-shape boundary requires no command dictionary. It uses the same +curated evidence as argument classification. A rare extension-shaped +subcommand becomes a path argument because the stronger path evidence wins. + ### 6.2 CWD verbs Verbs whose first non-flag positional arg becomes the cwd for subsequent diff --git a/openspec/changes/preserve-path-shaped-operands/.openspec.yaml b/openspec/changes/preserve-path-shaped-operands/.openspec.yaml new file mode 100644 index 0000000..1b062d3 --- /dev/null +++ b/openspec/changes/preserve-path-shaped-operands/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-04 diff --git a/openspec/changes/preserve-path-shaped-operands/design.md b/openspec/changes/preserve-path-shaped-operands/design.md new file mode 100644 index 0000000..a34996a --- /dev/null +++ b/openspec/changes/preserve-path-shaped-operands/design.md @@ -0,0 +1,71 @@ +## Context + +The parser builds a greedy native verb chain from lowercase word tokens. A lowercase filename can therefore leave no argument or path metadata. + +Three users define the required behavior: + +- A security-policy author needs a file operand and its directory scope before a persistent grant. +- An audit-tool author needs the parser's path result without a duplicate extension table. +- A private-CLI author needs unknown lowercase subcommand chains to remain intact. + +The public API is locked. Both shell parsers use the same greedy native-command policy. + +## Goals / Non-Goals + +**Goals:** + +- Preserve a path-shaped token as an argument after the command token. +- Use the existing path-shape rules as the canonical boundary. +- Keep Bash and PowerShell native-command results equivalent. +- Preserve plain lowercase subcommand chains. + +**Non-Goals:** + +- Define the grammar of Git or another command. +- Add source positions or an ordered public token list. +- Solve the relative-position problem from issue #62. +- Expand the file-extension table. + +## Decisions + +### Use the existing path-shape classifier as a verb boundary + +The greedy pass will stop before a token when `BashResolver.LooksLikePath` returns `true`. + +This rule applies only after the first command token. A path-shaped command name remains the command name. + +The argument pass already calls the same classifier. It will therefore emit the token with `IsPath` and `Resolved` metadata. + +### Apply the rule to both native-command parsers + +The Bash parser and the PowerShell native-command path must make the same boundary decision. + +Both paths will call the same `BashResolver.LooksLikePath` method. This choice prevents extension-table drift. + +### Keep the public AST unchanged + +Issue #64 does not require a new public type. The current `Arg` record already carries the required path result. + +An ordered public token API remains a possible answer for issue #62. That larger API is outside this change. + +### Preserve the existing greedy default + +Plain lowercase words still extend the verb chain. Commands such as `freshdesk ticket list` keep their current result. + +The parser will not use a command dictionary. The existing path evidence supplies the only new boundary. + +## Risks / Trade-offs + +- A legitimate subcommand can have a known file suffix. The curated path table limits this case, and path evidence wins for security consumers. +- The parsed verb becomes shorter for affected commands. Regression tests will lock the intentional result. +- The shared rule can change PowerShell native-command output. Equivalent tests will cover both shell parsers. + +## Migration Plan + +This change requires no consumer API migration. Consumers receive richer argument data after a package update. + +A revert restores the prior parser behavior if the rule causes an unexpected regression. + +## Open Questions + +Issue #62 can later add lexical provenance. That proposal must remain compatible with this parser boundary. diff --git a/openspec/changes/preserve-path-shaped-operands/proposal.md b/openspec/changes/preserve-path-shaped-operands/proposal.md new file mode 100644 index 0000000..12fcb66 --- /dev/null +++ b/openspec/changes/preserve-path-shaped-operands/proposal.md @@ -0,0 +1,27 @@ +## Why + +The greedy verb pass can consume a lowercase filename as a verb token. Security consumers then lose the path and its directory scope. + +## What Changes + +- Stop a native verb chain before a token that matches the existing path-shape rules. +- Return that token as an argument with the existing path metadata. +- Apply the same native-command rule to Bash and PowerShell. +- Preserve greedy verb chains for lowercase words that do not have a path shape. +- Add no command dictionary and make no public API change. + +## Capabilities + +### New Capabilities + +- `path-shaped-operands`: Preserve lowercase file operands and their resolved directory scope after multi-token commands. + +### Modified Capabilities + +None. + +## Impact + +The change affects the Bash and PowerShell native verb passes. It also affects their unit tests, corpora, and parser specifications. + +The public AST remains unchanged. The change adds no dependency and no native library. diff --git a/openspec/changes/preserve-path-shaped-operands/specs/path-shaped-operands/spec.md b/openspec/changes/preserve-path-shaped-operands/specs/path-shaped-operands/spec.md new file mode 100644 index 0000000..aacfc82 --- /dev/null +++ b/openspec/changes/preserve-path-shaped-operands/specs/path-shaped-operands/spec.md @@ -0,0 +1,59 @@ +## ADDED Requirements + +### Requirement: A path-shaped operand terminates a native verb chain +The parser SHALL stop a native verb chain before each later word token that matches the existing path-shape rules. + +#### Scenario: Bash file operand after a multi-token command +- **WHEN** Bash parses `git diff install-skills.sh` with `/home/user/repo` as the working directory +- **THEN** the verb tokens are `git` and `diff` +- **THEN** `install-skills.sh` is an argument with `IsPath` set to `true` +- **THEN** the resolved path is `/home/user/repo/install-skills.sh` + +#### Scenario: PowerShell native file operand after a multi-token command +- **WHEN** PowerShell parses `git diff install-skills.sh` with `/home/user/repo` as the working directory +- **THEN** the verb tokens are `git` and `diff` +- **THEN** `install-skills.sh` is an argument with `IsPath` set to `true` +- **THEN** the resolved path is `/home/user/repo/install-skills.sh` + +#### Scenario: A path-shaped command name remains the command +- **WHEN** Bash parses `deploy.sh status` +- **THEN** `deploy.sh` remains the first verb token + +#### Scenario: A PowerShell native path-shaped command name remains the command +- **WHEN** PowerShell parses `deploy.sh status` +- **THEN** `deploy.sh` remains the first verb token + +### Requirement: Path classification uses one canonical rule +The native verb pass SHALL use the same path-shape rules as the argument path classifier. + +#### Scenario: An unknown command has a file operand +- **WHEN** Bash parses `acme inspect report.json` with `/home/user/repo` as the working directory +- **THEN** the verb tokens are `acme` and `inspect` +- **THEN** `report.json` is a resolved path argument + +#### Scenario: A real non-Git command has a file operand +- **WHEN** Bash parses `kubectl apply deployment.yaml` with `/home/user/repo` as the working directory +- **THEN** the verb tokens are `kubectl` and `apply` +- **THEN** `deployment.yaml` is a resolved path argument + +#### Scenario: An explicit separator gives equivalent path metadata +- **WHEN** Bash parses `git diff -- install-skills.sh` with `/home/user/repo` as the working directory +- **THEN** `install-skills.sh` has the same path classification and resolved value as the form without `--` + +### Requirement: Plain lowercase subcommands keep the greedy behavior +The parser SHALL keep each later lowercase word in the verb chain when the word has no path shape. + +#### Scenario: Unknown private CLI subcommands +- **WHEN** Bash parses `freshdesk ticket list --status open` +- **THEN** the verb tokens are `freshdesk`, `ticket`, and `list` +- **THEN** `--status` and `open` remain arguments + +#### Scenario: Bare Git values remain narrow by default +- **WHEN** Bash parses `git push origin main` +- **THEN** the verb tokens are `git`, `push`, `origin`, and `main` + +#### Scenario: Path evidence wins over an extension-shaped subcommand +- **WHEN** Bash parses `tool plugin.sh list` +- **THEN** the only verb token is `tool` +- **THEN** `plugin.sh` is a path argument +- **THEN** `list` is a non-path argument diff --git a/openspec/changes/preserve-path-shaped-operands/tasks.md b/openspec/changes/preserve-path-shaped-operands/tasks.md new file mode 100644 index 0000000..9595b17 --- /dev/null +++ b/openspec/changes/preserve-path-shaped-operands/tasks.md @@ -0,0 +1,20 @@ +## 1. Contract + +- [x] 1.1 Update the Bash verb-chain specification with the path-shape boundary and use cases. +- [x] 1.2 Update the PowerShell native-command specification with the same boundary. + +## 2. Parser + +- [x] 2.1 Stop the Bash greedy verb pass before a path-shaped later token. +- [x] 2.2 Stop the PowerShell native verb pass before the same token shape. + +## 3. Verification + +- [x] 3.1 Add Bash tests for file operands, flags, separators, command names, and plain subcommands. +- [x] 3.2 Add equivalent PowerShell native-command tests. +- [x] 3.3 Add corpus coverage for the issue #64 reproduction. + +## 4. Completion + +- [x] 4.1 Update `IMPLEMENTATION_PLAN.md` with the completed issue #64 work. +- [x] 4.2 Run the OpenSpec check, build, tests, header check, and Slopwatch. diff --git a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs index 0603298..35fd6af 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Parsing/BashCommandParser.cs @@ -864,7 +864,11 @@ private static ClauseResult ParseClauseSegment( continue; } - if (fileVerbCarveout || !BashVerbs.IsVerbLikeToken(t)) + // Path evidence wins before the lexical verb heuristic. + // The argument pass uses the same classifier. + if (fileVerbCarveout + || BashResolver.LooksLikePath(t.Value) + || !BashVerbs.IsVerbLikeToken(t)) { break; } diff --git a/src/ShellSyntaxTree/Internal/Bash/Verbs/BashVerbs.cs b/src/ShellSyntaxTree/Internal/Bash/Verbs/BashVerbs.cs index d8c4ccb..393b920 100644 --- a/src/ShellSyntaxTree/Internal/Bash/Verbs/BashVerbs.cs +++ b/src/ShellSyntaxTree/Internal/Bash/Verbs/BashVerbs.cs @@ -128,14 +128,11 @@ internal static readonly IReadOnlyDictionary> /// /// /// Strict allow-list (leading [a-z], body [a-z0-9._-]) - /// over the more obvious negation-of-LooksLikePath because it stays - /// conservative for unknown shapes: a token like readme.md - /// satisfies the allow-list and would extend an unknown CLI's verb - /// chain, but the FileVerb carveout in BashCommandParser - /// short-circuits the common case (cat readme.md) before the - /// allow-list ever runs. Quoted strings are excluded so the user's - /// intent to treat bytes literally is preserved. The 64-char bound - /// is a defensive cap against pathological inputs. + /// remains independent from path classification. The caller applies + /// BashResolver.LooksLikePath first so a token such as + /// readme.md remains an argument. Quoted strings are excluded so + /// the user's intent to treat bytes literally is preserved. The + /// 64-char bound is a defensive cap against pathological inputs. /// internal static bool IsVerbLikeToken(in BashToken token) { diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs index ffcdd96..76d8f43 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Parsing/PwshCommandParser.cs @@ -710,7 +710,10 @@ private static ClassifiedVerb ClassifyVerb(List body, int start) continue; } - if (t.Kind != PwshTokenKind.Word || !PwshVerbs.IsNativeVerbLikeToken(t.Value)) + // Keep native-command boundaries equal across both shells. + if (t.Kind != PwshTokenKind.Word + || BashResolver.LooksLikePath(t.Value) + || !PwshVerbs.IsNativeVerbLikeToken(t.Value)) { break; } diff --git a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshVerbs.cs b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshVerbs.cs index b84705d..6d2f231 100644 --- a/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshVerbs.cs +++ b/src/ShellSyntaxTree/Internal/Pwsh/Verbs/PwshVerbs.cs @@ -78,7 +78,8 @@ internal static bool IsPwshHost(string? verb) => /// case-sensitive: the leading-lowercase rule is the only /// signal that stops the greedy walk at a capitalized identifier /// (dotnet ef migrations add InitialCreate stops at - /// InitialCreate). + /// InitialCreate). The caller first rejects tokens that match the + /// shared Bash path-shape rules. /// internal static bool IsNativeVerbLikeToken(string value) { diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/150_git_diff_lowercase_shell_file.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/150_git_diff_lowercase_shell_file.json new file mode 100644 index 0000000..f0690fd --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/150_git_diff_lowercase_shell_file.json @@ -0,0 +1,26 @@ +{ + "name": "Path-shaped operand: git diff lowercase shell file", + "input": "git diff install-skills.sh", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["git", "diff"], + "args": [ + { + "raw": "install-skills.sh", + "kind": "Literal", + "isPath": true, + "isFlag": false, + "resolved": "/work/install-skills.sh" + } + ], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + } + ] + }, + "notes": "Issue #64: the path-shape rule stops the greedy verb pass before a lowercase file operand." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/bash/151_kubectl_apply_deployment_yaml.json b/tests/ShellSyntaxTree.Tests/Corpus/bash/151_kubectl_apply_deployment_yaml.json new file mode 100644 index 0000000..e417c05 --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/bash/151_kubectl_apply_deployment_yaml.json @@ -0,0 +1,26 @@ +{ + "name": "Path-shaped operand: kubectl apply YAML file", + "input": "kubectl apply deployment.yaml", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": ["kubectl", "apply"], + "args": [ + { + "raw": "deployment.yaml", + "kind": "Literal", + "isPath": true, + "isFlag": false, + "resolved": "/work/deployment.yaml" + } + ], + "redirects": [], + "isSubshell": false, + "isCommandStringWrapped": false + } + ] + }, + "notes": "Issue #64: a real non-Git CLI exposes a lowercase YAML file without a command-specific rule." +} diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/038_native_python_script.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/038_native_python_script.json index 88e81a0..d59d3f9 100644 --- a/tests/ShellSyntaxTree.Tests/Corpus/powershell/038_native_python_script.json +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/038_native_python_script.json @@ -7,10 +7,16 @@ { "operator": "None", "verb": [ - "python", - "script.py" + "python" + ], + "args": [ + { + "raw": "script.py", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/script.py" + } ], - "args": [], "redirects": [] } ] diff --git a/tests/ShellSyntaxTree.Tests/Corpus/powershell/219_native_kubectl_apply_yaml.json b/tests/ShellSyntaxTree.Tests/Corpus/powershell/219_native_kubectl_apply_yaml.json new file mode 100644 index 0000000..2ad7b1f --- /dev/null +++ b/tests/ShellSyntaxTree.Tests/Corpus/powershell/219_native_kubectl_apply_yaml.json @@ -0,0 +1,26 @@ +{ + "name": "Native kubectl apply yaml", + "input": "kubectl apply deployment.yaml", + "expected": { + "isUnparseable": false, + "clauses": [ + { + "operator": "None", + "verb": [ + "kubectl", + "apply" + ], + "args": [ + { + "raw": "deployment.yaml", + "kind": "Literal", + "isPath": true, + "resolved": "C:/work/deployment.yaml" + } + ], + "redirects": [] + } + ] + }, + "notes": "A real non-Git CLI exposes a lowercase YAML file without a command-specific rule." +} diff --git a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs index d5235f6..2d99fdb 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/BashCommandParserTests.cs @@ -117,6 +117,74 @@ public void Two_token_verb_dotnet_test() Assert.Empty(clause.Args); } + [Fact] + public void Path_shaped_operand_terminates_greedy_verb_chain() + { + var clause = Assert.Single(Parse("git diff install-skills.sh").Clauses); + + Assert.Equal(new[] { "git", "diff" }, clause.Verb.Tokens); + var operand = Assert.Single(clause.Args); + Assert.Equal("install-skills.sh", operand.Raw); + Assert.True(operand.IsPath); + Assert.Equal("/work/install-skills.sh", operand.Resolved); + } + + [Fact] + public void Unknown_command_uses_path_shape_without_a_command_rule() + { + var clause = Assert.Single(Parse("acme inspect report.json").Clauses); + + Assert.Equal(new[] { "acme", "inspect" }, clause.Verb.Tokens); + var operand = Assert.Single(clause.Args); + Assert.True(operand.IsPath); + Assert.Equal("/work/report.json", operand.Resolved); + } + + [Fact] + public void Path_shaped_operand_after_transparent_flag_pair_is_an_arg() + { + var clause = Assert.Single(Parse("git -C /repo diff install-skills.sh").Clauses); + + Assert.Equal(new[] { "git", "diff" }, clause.Verb.Tokens); + Assert.Equal(new[] { "-C", "/repo", "install-skills.sh" }, + clause.Args.Select(arg => arg.Raw).ToArray()); + Assert.True(clause.Args[2].IsPath); + } + + [Fact] + public void Explicit_separator_produces_the_same_file_metadata() + { + var implicitClause = Assert.Single(Parse("git diff install-skills.sh").Clauses); + var explicitClause = Assert.Single(Parse("git diff -- install-skills.sh").Clauses); + + var implicitOperand = Assert.Single(implicitClause.Args); + var explicitOperand = Assert.Single(explicitClause.Args, arg => !arg.IsFlag); + Assert.Equal(implicitOperand.Raw, explicitOperand.Raw); + Assert.Equal(implicitOperand.IsPath, explicitOperand.IsPath); + Assert.Equal(implicitOperand.Resolved, explicitOperand.Resolved); + } + + [Fact] + public void Path_shaped_first_token_remains_the_command() + { + var clause = Assert.Single(Parse("deploy.sh status").Clauses); + + Assert.Equal(new[] { "deploy.sh", "status" }, clause.Verb.Tokens); + Assert.Empty(clause.Args); + } + + [Fact] + public void Known_file_suffix_wins_over_an_extension_shaped_subcommand() + { + var clause = Assert.Single(Parse("tool plugin.sh list").Clauses); + + Assert.Equal(new[] { "tool" }, clause.Verb.Tokens); + Assert.Equal(new[] { "plugin.sh", "list" }, + clause.Args.Select(arg => arg.Raw).ToArray()); + Assert.True(clause.Args[0].IsPath); + Assert.False(clause.Args[1].IsPath); + } + [Fact] public void Greedy_verb_chain_walks_through_docker_compose_up_nginx() { diff --git a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs index d5200ef..a3ebe4e 100644 --- a/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs +++ b/tests/ShellSyntaxTree.Tests/Parsing/PwshCommandParserTests.cs @@ -94,6 +94,27 @@ public void Native_command_uses_greedy_verb_chain() Assert.Equal(new[] { "git", "push", "origin", "main" }, clause.Verb.Tokens); } + [Fact] + public void Native_path_shaped_operand_terminates_greedy_verb_chain() + { + var clause = Assert.Single(Parse("git diff install-skills.sh").Clauses); + + Assert.Equal(new[] { "git", "diff" }, clause.Verb.Tokens); + var operand = Assert.Single(clause.Args); + Assert.Equal("install-skills.sh", operand.Raw); + Assert.True(operand.IsPath); + Assert.Equal("C:/work/install-skills.sh", operand.Resolved); + } + + [Fact] + public void Native_path_shaped_first_token_remains_the_command() + { + var clause = Assert.Single(Parse("deploy.sh status").Clauses); + + Assert.Equal(new[] { "deploy.sh", "status" }, clause.Verb.Tokens); + Assert.Empty(clause.Args); + } + [Fact] public void Native_chain_stops_at_a_capitalized_token() { diff --git a/tools/PwshCorpusTool/CorpusManifest.cs b/tools/PwshCorpusTool/CorpusManifest.cs index 4de03bc..8102043 100644 --- a/tools/PwshCorpusTool/CorpusManifest.cs +++ b/tools/PwshCorpusTool/CorpusManifest.cs @@ -478,5 +478,9 @@ private static string B64(string s) => + "can never bind, so the value safe-fails to DynamicSkip."), E("bind_question_mark_help", "Get-Help -?", "'-?' is a single parameter token, not a bare dash plus a '?' glob."), + + // ---- Issue #64: path-shaped operands after native verb chains ---- + E("native_kubectl_apply_yaml", "kubectl apply deployment.yaml", + "A real non-Git CLI exposes a lowercase YAML file without a command-specific rule."), }; }