← back to Cli Printing Press
fix(cli): validate-narrative splits piped recipes and strips redirects (#1517)
62838d047cde2b1ffbd440f54069fbc9b765a584 · 2026-05-16 10:38:03 -0700 · Trevin Chow
* fix(cli): validate-narrative splits piped recipes and strips redirects (#1455)
Recipes that pipe a CLI invocation into jq/head/xargs (or feed input via
`< file`) used to fail validate-narrative wholesale — every shell-shaped
command was classified as Unsupported with a "pipe-skipped" reason, so
realistic narrative recipes had to drop their tails to ship.
Teach the splitter to:
- Split on top-level `|` (in addition to `&&`, `||`, `;`) and tag any
segment that sits to the right of a pipe as AfterPipe so the validator
skips it with a `pipe-skipped:` note instead of failing the recipe.
- Strip top-level `<`, `>`, `>>` redirects from each runnable segment
before validation, recording each removed token as a
`redirect-stripped:` note. Redirects inside quotes and `2>&1`-style fd
duplications are left intact.
Result is reflected on Result.Notes (new field). Recipes like
`yt-cli sync --json | jq | head -c 2000` and `cli bulk --stdin < keys.txt`
now validate against their leading segment and ship clean, matching the
acceptance criteria in #1455.
Extends #1271, which handled `&&`/`;` and explicitly deferred `|` as a
known limitation.
* fix(cli): stripRedirects respects digit-prefix fd dup + shell quoting (#1455)
Addresses two P2 Greptile findings on #1517:
1. `>&file` (POSIX shorthand for stdout+stderr to a file) was being
left intact because the fd-duplication guard fired on any `>&`.
The signal is actually a *preceding* digit AND a following `&`
(`2>&1`, `1>&2`). Check both before bypassing the strip.
2. Quoted redirect targets like `< 'file with spaces.txt'` were
truncated at the first space inside the quote. Walk the file
token with the same single/double-quote and backslash awareness
that the outer scanner uses so the full quoted name lands in the
redirect-stripped note.
* test(cli): order Result.Notes left-to-right + cover reset operators (#1455)
Addresses two follow-up Greptile findings on PR #1517:
- Collect pipe-skipped and redirect-stripped notes in a single
left-to-right pass over segments so a recipe like
\`cmd <file | jq\` records the redirect note before the pipe note,
matching the source token order. Adds
TestValidate_MixedRedirectAndPipeNotesAreLeftToRight to pin the
contract.
- Extend TestSplitShellChain with cases for \`||\` and \`;\` after a
pipe so the afterPipe reset for those operators is covered alongside
the existing \`&&\` case. All three share the same reset code path;
the new cases close the silent-regression gap.
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
Files touched
M internal/narrativecheck/narrativecheck.goM internal/narrativecheck/narrativecheck_test.go
Diff
commit 62838d047cde2b1ffbd440f54069fbc9b765a584
Author: Trevin Chow <trevin@trevinchow.com>
Date: Sat May 16 10:38:03 2026 -0700
fix(cli): validate-narrative splits piped recipes and strips redirects (#1517)
* fix(cli): validate-narrative splits piped recipes and strips redirects (#1455)
Recipes that pipe a CLI invocation into jq/head/xargs (or feed input via
`< file`) used to fail validate-narrative wholesale — every shell-shaped
command was classified as Unsupported with a "pipe-skipped" reason, so
realistic narrative recipes had to drop their tails to ship.
Teach the splitter to:
- Split on top-level `|` (in addition to `&&`, `||`, `;`) and tag any
segment that sits to the right of a pipe as AfterPipe so the validator
skips it with a `pipe-skipped:` note instead of failing the recipe.
- Strip top-level `<`, `>`, `>>` redirects from each runnable segment
before validation, recording each removed token as a
`redirect-stripped:` note. Redirects inside quotes and `2>&1`-style fd
duplications are left intact.
Result is reflected on Result.Notes (new field). Recipes like
`yt-cli sync --json | jq | head -c 2000` and `cli bulk --stdin < keys.txt`
now validate against their leading segment and ship clean, matching the
acceptance criteria in #1455.
Extends #1271, which handled `&&`/`;` and explicitly deferred `|` as a
known limitation.
* fix(cli): stripRedirects respects digit-prefix fd dup + shell quoting (#1455)
Addresses two P2 Greptile findings on #1517:
1. `>&file` (POSIX shorthand for stdout+stderr to a file) was being
left intact because the fd-duplication guard fired on any `>&`.
The signal is actually a *preceding* digit AND a following `&`
(`2>&1`, `1>&2`). Check both before bypassing the strip.
2. Quoted redirect targets like `< 'file with spaces.txt'` were
truncated at the first space inside the quote. Walk the file
token with the same single/double-quote and backslash awareness
that the outer scanner uses so the full quoted name lands in the
redirect-stripped note.
* test(cli): order Result.Notes left-to-right + cover reset operators (#1455)
Addresses two follow-up Greptile findings on PR #1517:
- Collect pipe-skipped and redirect-stripped notes in a single
left-to-right pass over segments so a recipe like
\`cmd <file | jq\` records the redirect note before the pipe note,
matching the source token order. Adds
TestValidate_MixedRedirectAndPipeNotesAreLeftToRight to pin the
contract.
- Extend TestSplitShellChain with cases for \`||\` and \`;\` after a
pipe so the afterPipe reset for those operators is covered alongside
the existing \`&&\` case. All three share the same reset code path;
the new cases close the silent-regression gap.
---------
Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com>
---
internal/narrativecheck/narrativecheck.go | 243 +++++++++++++++++++++----
internal/narrativecheck/narrativecheck_test.go | 206 ++++++++++++++++++---
2 files changed, 390 insertions(+), 59 deletions(-)
diff --git a/internal/narrativecheck/narrativecheck.go b/internal/narrativecheck/narrativecheck.go
index d211a2c0..7819caed 100644
--- a/internal/narrativecheck/narrativecheck.go
+++ b/internal/narrativecheck/narrativecheck.go
@@ -57,6 +57,11 @@ type Result struct {
Words string `json:"words,omitempty"`
Status Status `json:"status"`
Error string `json:"error,omitempty"`
+ // Notes carries structural annotations about shell pieces that were
+ // excluded from validation. Each entry is shaped `<reason>: <fragment>`,
+ // e.g. `pipe-skipped: jq '.items[]'` or `redirect-stripped: <
+ // keywords.txt`. Empty when the recipe is plain (no pipes, no redirects).
+ Notes []string `json:"notes,omitempty"`
}
type Report struct {
@@ -181,7 +186,7 @@ func loadCommands(researchPath string) ([]sectionCommand, error) {
// non-identifier character. Hyphens stay because Cobra subcommands use
// them (`list-projects`).
func classify(ctx context.Context, binaryPath string, section Section, command string, opts Options) Result {
- segments, hasPipe, err := splitShellChain(command)
+ segments, err := splitShellChain(command)
if err != nil {
return Result{
Section: section,
@@ -190,36 +195,58 @@ func classify(ctx context.Context, binaryPath string, section Section, command s
Error: err.Error(),
}
}
- if hasPipe {
- return Result{
- Section: section,
- Command: command,
- Status: StatusUnsupported,
- Error: "pipe-skipped: command contains a top-level `|` which cannot run safely under PRINTING_PRESS_VERIFY=1",
- }
+
+ // Walk segments left-to-right, emitting notes in the order they appear
+ // in the original command so authors can reconstruct the recipe by
+ // reading down the Notes slice.
+ var notes []string
+ type runnableSegment struct {
+ index int
+ text string
+ cleaned string
}
- if len(segments) <= 1 {
- seg := command
- if len(segments) == 1 {
- seg = segments[0]
+ var runnable []runnableSegment
+ for i, seg := range segments {
+ if seg.AfterPipe {
+ notes = append(notes, "pipe-skipped: "+seg.Text)
+ continue
}
- r := classifySegment(ctx, binaryPath, section, seg, opts)
+ cleaned, redirects := stripRedirects(seg.Text)
+ for _, r := range redirects {
+ notes = append(notes, "redirect-stripped: "+r)
+ }
+ runnable = append(runnable, runnableSegment{index: i, text: seg.Text, cleaned: cleaned})
+ }
+
+ finish := func(r Result) Result {
r.Command = command
+ r.Notes = append(r.Notes, notes...)
return r
}
+ if len(runnable) == 0 {
+ // Every chained segment landed on the right side of a pipe — there
+ // is no runnable head. Surface this as empty-words so the author
+ // notices, but keep the pipe-skipped notes for context.
+ return finish(Result{
+ Section: section,
+ Status: StatusEmptyWords,
+ Error: "command has no runnable segment (every chained segment is pipe-skipped)",
+ })
+ }
+
var last Result
- for i, seg := range segments {
- sub := classifySegment(ctx, binaryPath, section, seg, opts)
+ for i, seg := range runnable {
+ sub := classifySegment(ctx, binaryPath, section, seg.cleaned, opts)
if sub.Status != StatusOK {
- sub.Command = command
- sub.Error = fmt.Sprintf("segment %d (%q): %s", i+1, seg, sub.Error)
- return sub
+ if len(runnable) > 1 {
+ sub.Error = fmt.Sprintf("segment %d (%q): %s", i+1, seg.cleaned, sub.Error)
+ }
+ return finish(sub)
}
last = sub
}
- last.Command = command
- return last
+ return finish(last)
}
func classifySegment(ctx context.Context, binaryPath string, section Section, command string, opts Options) Result {
@@ -344,25 +371,36 @@ func extractSubcommandWords(command string) []string {
return words
}
+// chainSegment is one runnable (or pipe-skipped) piece of a recipe command.
+type chainSegment struct {
+ // Text is the segment as it appeared in the source, with surrounding
+ // whitespace trimmed. Pipe and redirect tokens are NOT stripped here —
+ // stripRedirects handles that downstream.
+ Text string
+ // AfterPipe is true when this segment sat to the right of a top-level
+ // `|` operator. The validator skips these segments because their input
+ // would normally arrive over a shell pipe.
+ AfterPipe bool
+}
+
// splitShellChain walks command and returns the segments separated by
-// top-level `&&`, `||`, or `;` operators. Quoted text is preserved.
-// hasPipe is true when a bare `|` appears at the top level; callers
-// should treat such commands as unrunnable in verify mode rather than
-// attempting to validate the segments around the pipe.
+// top-level `&&`, `||`, `;`, or `|` operators. Quoted text is preserved.
+// Segments after a top-level `|` carry AfterPipe=true; the validator
+// reports those as `pipe-skipped` rather than executing them.
//
// Backslash-escapes and quoted-string handling mirror shellargs.Split
// so the segments are safe to feed back into shellargs.Split.
-func splitShellChain(command string) ([]string, bool, error) {
+func splitShellChain(command string) ([]chainSegment, error) {
var (
- segments []string
- quote rune
- escaped bool
- hasPipe bool
- start int
+ segments []chainSegment
+ quote rune
+ escaped bool
+ afterPipe bool
+ start int
)
flush := func(end int) {
if seg := strings.TrimSpace(command[start:end]); seg != "" {
- segments = append(segments, seg)
+ segments = append(segments, chainSegment{Text: seg, AfterPipe: afterPipe})
}
}
for i := 0; i < len(command); i++ {
@@ -396,25 +434,162 @@ func splitShellChain(command string) ([]string, bool, error) {
flush(i)
i++
start = i + 1
+ // && resets the pipeline; segments after && start fresh.
+ afterPipe = false
}
case '|':
if i+1 < len(command) && command[i+1] == '|' {
flush(i)
i++
start = i + 1
+ // || resets the pipeline too.
+ afterPipe = false
continue
}
- hasPipe = true
+ // Bare `|` ends the current runnable segment and marks every
+ // subsequent segment in this && / || / ; group as pipe-skipped.
+ flush(i)
+ start = i + 1
+ afterPipe = true
case ';':
flush(i)
start = i + 1
+ afterPipe = false
}
}
if quote != 0 {
- return nil, false, fmt.Errorf("unclosed %s quote in %q", quoteName(quote), command)
+ return nil, fmt.Errorf("unclosed %s quote in %q", quoteName(quote), command)
}
flush(len(command))
- return segments, hasPipe, nil
+ return segments, nil
+}
+
+// stripRedirects removes top-level shell redirects (`<file`, `>file`, `>>file`)
+// from a segment and returns the cleaned text plus the human-readable redirect
+// fragments that were excised (e.g. `< keywords.txt`). The validator records
+// the fragments as `redirect-stripped` notes so authors see what the runtime
+// dropped. Redirects inside quoted strings and `2>&1`-style fd duplications
+// are left alone.
+func stripRedirects(segment string) (string, []string) {
+ var (
+ cleaned strings.Builder
+ redirects []string
+ quote rune
+ escaped bool
+ )
+ cleaned.Grow(len(segment))
+ bytes := []byte(segment)
+ i := 0
+ for i < len(bytes) {
+ c := bytes[i]
+ if escaped {
+ cleaned.WriteByte(c)
+ escaped = false
+ i++
+ continue
+ }
+ if quote != 0 {
+ cleaned.WriteByte(c)
+ switch {
+ case quote == '\'' && c == '\'':
+ quote = 0
+ case quote == '"' && c == '\\':
+ escaped = true
+ case quote == '"' && c == '"':
+ quote = 0
+ }
+ i++
+ continue
+ }
+ switch c {
+ case '\\':
+ cleaned.WriteByte(c)
+ escaped = true
+ i++
+ case '\'', '"':
+ cleaned.WriteByte(c)
+ quote = rune(c)
+ i++
+ case '<', '>':
+ // fd duplication like `2>&1` is signaled by a preceding digit AND
+ // a following `&`. Bare `>&file` is `&>file` shorthand (a real
+ // redirect target) and must still be stripped, so the guard only
+ // fires when both signals are present.
+ prevIsDigit := cleaned.Len() > 0 && cleaned.String()[cleaned.Len()-1] >= '0' && cleaned.String()[cleaned.Len()-1] <= '9'
+ if c == '>' && prevIsDigit && i+1 < len(bytes) && bytes[i+1] == '&' {
+ cleaned.WriteByte(c)
+ i++
+ continue
+ }
+ op := string(c)
+ if c == '>' && i+1 < len(bytes) && bytes[i+1] == '>' {
+ op = ">>"
+ i++
+ }
+ i++
+ for i < len(bytes) && (bytes[i] == ' ' || bytes[i] == '\t') {
+ i++
+ }
+ fileStart := i
+ fileQuote := rune(0)
+ fileEsc := false
+ for i < len(bytes) {
+ b := bytes[i]
+ if fileEsc {
+ fileEsc = false
+ i++
+ continue
+ }
+ if fileQuote == '\'' {
+ if b == '\'' {
+ fileQuote = 0
+ }
+ i++
+ continue
+ }
+ if fileQuote == '"' {
+ switch b {
+ case '\\':
+ fileEsc = true
+ case '"':
+ fileQuote = 0
+ }
+ i++
+ continue
+ }
+ switch b {
+ case '\'', '"':
+ fileQuote = rune(b)
+ i++
+ continue
+ case '\\':
+ fileEsc = true
+ i++
+ continue
+ case ' ', '\t':
+ goto fileDone
+ }
+ i++
+ }
+ fileDone:
+ fragment := strings.TrimSpace(op + " " + string(bytes[fileStart:i]))
+ redirects = append(redirects, fragment)
+ // Skip any whitespace that follows the file token so a
+ // redirect-stripped span sandwiched between two flags collapses
+ // to a single space.
+ for i < len(bytes) && (bytes[i] == ' ' || bytes[i] == '\t') {
+ i++
+ }
+ trimmed := strings.TrimRight(cleaned.String(), " \t")
+ cleaned.Reset()
+ cleaned.WriteString(trimmed)
+ cleaned.WriteByte(' ')
+ default:
+ cleaned.WriteByte(c)
+ i++
+ }
+ }
+ return strings.TrimSpace(cleaned.String()), redirects
}
func quoteName(r rune) string {
diff --git a/internal/narrativecheck/narrativecheck_test.go b/internal/narrativecheck/narrativecheck_test.go
index f6c1bd6e..ce77601a 100644
--- a/internal/narrativecheck/narrativecheck_test.go
+++ b/internal/narrativecheck/narrativecheck_test.go
@@ -7,6 +7,7 @@ import (
"os/exec"
"path/filepath"
"reflect"
+ "slices"
"strings"
"sync"
"testing"
@@ -259,25 +260,28 @@ func TestSplitShellChain(t *testing.T) {
cases := []struct {
name string
in string
- segments []string
- hasPipe bool
+ segments []chainSegment
wantErr bool
}{
- {"plain command", "stub widgets list", []string{"stub widgets list"}, false, false},
- {"and-chain", "stub sync && stub list --within 60d", []string{"stub sync", "stub list --within 60d"}, false, false},
- {"semicolon-chain", "stub sync ; stub list", []string{"stub sync", "stub list"}, false, false},
- {"or-chain", "stub sync || stub list", []string{"stub sync", "stub list"}, false, false},
- {"top-level pipe sets flag, leaves segments unsplit", "stub list | grep foo", []string{"stub list | grep foo"}, true, false},
- {"and inside double quotes", `stub run --msg "a && b"`, []string{`stub run --msg "a && b"`}, false, false},
- {"semicolon inside single quotes", "stub run --msg 'a ; b'", []string{"stub run --msg 'a ; b'"}, false, false},
- {"pipe inside quotes is not top-level", `stub run --msg "a | b"`, []string{`stub run --msg "a | b"`}, false, false},
- {"empty trailing segment dropped", "stub sync &&", []string{"stub sync"}, false, false},
- {"unclosed quote errors", `stub run --msg "open`, nil, false, true},
+ {"plain command", "stub widgets list", []chainSegment{{Text: "stub widgets list"}}, false},
+ {"and-chain", "stub sync && stub list --within 60d", []chainSegment{{Text: "stub sync"}, {Text: "stub list --within 60d"}}, false},
+ {"semicolon-chain", "stub sync ; stub list", []chainSegment{{Text: "stub sync"}, {Text: "stub list"}}, false},
+ {"or-chain", "stub sync || stub list", []chainSegment{{Text: "stub sync"}, {Text: "stub list"}}, false},
+ {"top-level pipe splits, tail is AfterPipe", "stub list | grep foo", []chainSegment{{Text: "stub list"}, {Text: "grep foo", AfterPipe: true}}, false},
+ {"pipe chain with three commands marks all tails AfterPipe", "stub list | jq | head", []chainSegment{{Text: "stub list"}, {Text: "jq", AfterPipe: true}, {Text: "head", AfterPipe: true}}, false},
+ {"and after pipe resets the pipeline", "stub list | jq && stub show 42", []chainSegment{{Text: "stub list"}, {Text: "jq", AfterPipe: true}, {Text: "stub show 42"}}, false},
+ {"or after pipe resets the pipeline", "stub list | jq || stub show 42", []chainSegment{{Text: "stub list"}, {Text: "jq", AfterPipe: true}, {Text: "stub show 42"}}, false},
+ {"semicolon after pipe resets the pipeline", "stub list | jq ; stub show 42", []chainSegment{{Text: "stub list"}, {Text: "jq", AfterPipe: true}, {Text: "stub show 42"}}, false},
+ {"and inside double quotes", `stub run --msg "a && b"`, []chainSegment{{Text: `stub run --msg "a && b"`}}, false},
+ {"semicolon inside single quotes", "stub run --msg 'a ; b'", []chainSegment{{Text: "stub run --msg 'a ; b'"}}, false},
+ {"pipe inside quotes is not top-level", `stub run --msg "a | b"`, []chainSegment{{Text: `stub run --msg "a | b"`}}, false},
+ {"empty trailing segment dropped", "stub sync &&", []chainSegment{{Text: "stub sync"}}, false},
+ {"unclosed quote errors", `stub run --msg "open`, nil, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
- segs, hasPipe, err := splitShellChain(tc.in)
+ segs, err := splitShellChain(tc.in)
if tc.wantErr {
if err == nil {
t.Fatalf("splitShellChain(%q) = nil error, want error", tc.in)
@@ -287,19 +291,16 @@ func TestSplitShellChain(t *testing.T) {
if err != nil {
t.Fatalf("splitShellChain(%q) errored: %v", tc.in, err)
}
- if hasPipe != tc.hasPipe {
- t.Errorf("hasPipe = %v, want %v", hasPipe, tc.hasPipe)
- }
want := tc.segments
if want == nil {
- want = []string{}
+ want = []chainSegment{}
}
got := segs
if got == nil {
- got = []string{}
+ got = []chainSegment{}
}
if !reflect.DeepEqual(got, want) {
- t.Errorf("segments = %q, want %q", segs, tc.segments)
+ t.Errorf("segments = %+v, want %+v", got, want)
}
})
}
@@ -393,13 +394,84 @@ func TestValidate_TrailingOperatorDoesNotLeakIntoArgs(t *testing.T) {
}
}
-func TestValidate_PipeRecipeSkipsWithReason(t *testing.T) {
+// TestValidate_PipeRecipeValidatesLeadingSegment pins the issue #1455 contract:
+// recipes that pipe their CLI output into jq/head/xargs are now validated by
+// running the leading segment only and recording each pipe tail as a
+// `pipe-skipped:` note. Trailing pipes no longer disqualify the whole recipe.
+func TestValidate_PipeRecipeValidatesLeadingSegment(t *testing.T) {
+ t.Parallel()
+
+ binary := buildStubBinary(t)
+ research := writeFile(t, `{"narrative":{
+ "recipes":[
+ {"command":"stub widgets list | jq '.items[]' | head -c 2000"}
+ ]
+ }}`)
+
+ report, err := Validate(context.Background(), research, binary)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.HasFailures() {
+ t.Fatalf("piped recipe should validate cleanly on the leading segment, got %+v", report)
+ }
+ if report.Walked != 1 {
+ t.Fatalf("Walked = %d, want 1", report.Walked)
+ }
+ got := report.Results[0]
+ if got.Status != StatusOK {
+ t.Fatalf("Status = %q, want %q", got.Status, StatusOK)
+ }
+ wantNotes := []string{
+ "pipe-skipped: jq '.items[]'",
+ "pipe-skipped: head -c 2000",
+ }
+ if !reflect.DeepEqual(got.Notes, wantNotes) {
+ t.Errorf("Notes = %q, want %q", got.Notes, wantNotes)
+ }
+}
+
+// TestValidate_MixedRedirectAndPipeNotesAreLeftToRight pins the textual
+// ordering of Result.Notes: a recipe whose leading segment owns a redirect
+// and whose tail is piped should record the redirect-stripped note before
+// the pipe-skipped note, matching the order tokens appear in the source.
+func TestValidate_MixedRedirectAndPipeNotesAreLeftToRight(t *testing.T) {
+ t.Parallel()
+
+ binary := buildStubBinary(t)
+ research := writeFile(t, `{"narrative":{
+ "recipes":[
+ {"command":"stub widgets list < keywords.txt | jq '.'"}
+ ]
+ }}`)
+
+ report, err := Validate(context.Background(), research, binary)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if report.HasFailures() {
+ t.Fatalf("mixed redirect+pipe recipe should validate, got %+v", report)
+ }
+ got := report.Results[0]
+ wantNotes := []string{
+ "redirect-stripped: < keywords.txt",
+ "pipe-skipped: jq '.'",
+ }
+ if !reflect.DeepEqual(got.Notes, wantNotes) {
+ t.Errorf("Notes = %q, want %q (left-to-right textual order)", got.Notes, wantNotes)
+ }
+}
+
+// TestValidate_RedirectStripValidatesCleanedHead covers the second leg of
+// issue #1455: a `<file` input redirect is excised from the leading segment
+// before validation and recorded as a `redirect-stripped:` note.
+func TestValidate_RedirectStripValidatesCleanedHead(t *testing.T) {
t.Parallel()
binary := buildStubBinary(t)
research := writeFile(t, `{"narrative":{
"recipes":[
- {"command":"stub widgets list | jq ."}
+ {"command":"stub widgets list < keywords.txt"}
]
}}`)
@@ -407,12 +479,15 @@ func TestValidate_PipeRecipeSkipsWithReason(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- if report.Unsupported != 1 {
- t.Fatalf("piped recipe should classify as Unsupported, got %+v", report)
+ if report.HasFailures() {
+ t.Fatalf("recipe with input redirect should validate the stripped command, got %+v", report)
}
got := report.Results[0]
- if !strings.Contains(got.Error, "pipe-skipped") {
- t.Errorf("error should explain the pipe skip: %s", got.Error)
+ if got.Status != StatusOK {
+ t.Fatalf("Status = %q, want %q", got.Status, StatusOK)
+ }
+ if !slices.Contains(got.Notes, "redirect-stripped: < keywords.txt") {
+ t.Errorf("Notes should record the stripped redirect, got %q", got.Notes)
}
}
@@ -502,3 +577,84 @@ func main() {
}
return stubPath
}
+
+func TestStripRedirects(t *testing.T) {
+ t.Parallel()
+
+ cases := []struct {
+ name string
+ in string
+ wantText string
+ wantPaths []string
+ }{
+ {
+ name: "no redirects",
+ in: "stub widgets list --json",
+ wantText: "stub widgets list --json",
+ },
+ {
+ name: "trailing input redirect",
+ in: "stub bulk --stdin --json < keywords.txt",
+ wantText: "stub bulk --stdin --json",
+ wantPaths: []string{"< keywords.txt"},
+ },
+ {
+ name: "trailing output redirect",
+ in: "stub export > out.json",
+ wantText: "stub export",
+ wantPaths: []string{"> out.json"},
+ },
+ {
+ name: "append redirect",
+ in: "stub log >> session.log",
+ wantText: "stub log",
+ wantPaths: []string{">> session.log"},
+ },
+ {
+ name: "leading flag is preserved when redirect interleaves",
+ in: "stub run < in.txt --json",
+ wantText: "stub run --json",
+ wantPaths: []string{"< in.txt"},
+ },
+ {
+ name: "fd duplication is left alone",
+ in: "stub run 2>&1",
+ wantText: "stub run 2>&1",
+ },
+ {
+ name: "bare >&file is a real redirect target (no digit prefix)",
+ in: "stub run >&combined.log",
+ wantText: "stub run",
+ wantPaths: []string{"> &combined.log"},
+ },
+ {
+ name: "single-quoted filename with spaces stays whole",
+ in: "stub bulk --stdin < 'file with spaces.txt'",
+ wantText: "stub bulk --stdin",
+ wantPaths: []string{"< 'file with spaces.txt'"},
+ },
+ {
+ name: "double-quoted filename with spaces stays whole",
+ in: `stub export > "out file.json"`,
+ wantText: "stub export",
+ wantPaths: []string{`> "out file.json"`},
+ },
+ {
+ name: "redirect inside single quote preserved",
+ in: "stub run --msg 'a < b > c'",
+ wantText: "stub run --msg 'a < b > c'",
+ },
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ gotText, gotPaths := stripRedirects(tc.in)
+ if gotText != tc.wantText {
+ t.Errorf("text = %q, want %q", gotText, tc.wantText)
+ }
+ if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
+ t.Errorf("paths = %q, want %q", gotPaths, tc.wantPaths)
+ }
+ })
+ }
+}
← f5ea270d fix(cli): match both singular and plural required-flag cobra
·
back to Cli Printing Press
·
feat(cli): add --auth-preference flag for catalog-driven sch 34ca8149 →