From 7096a902352fc097d062bf6cdfb38c7cc295110b Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 21:54:44 +0200 Subject: [PATCH] test(08-07): add failing repeatable-flag RED anchor for bonfire - TestPhase8RedBonfireFlags asserts Input.Flags preserves ordered repeated --redirect-uri/--scope values while existing scalar --mode flags via Input.Flag stay unaffected - Adds the compiling seam (Flag.Repeatable, Input.Flags, cobraInput.Flags) without wiring Cobra StringSlice registration yet, so the test fails with PHASE8_RED:bonfire-flags (D-19) --- bonfire/command.go | 25 +++++++++++++++++++ bonfire/output_test.go | 56 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/bonfire/command.go b/bonfire/command.go index 5e0551d..9294782 100644 --- a/bonfire/command.go +++ b/bonfire/command.go @@ -28,6 +28,13 @@ type Flag struct { Default string // Bare allows --flag without a value; cobra stores "true". Bare bool + // Repeatable registers this flag as an ordered, multi-occurrence + // string flag (Cobra StringSlice) instead of a scalar string flag + // (08-CONTEXT.md D-19; 08-PATTERNS.md "bonfire repeatable-flag gap"). + // Repeated `--name=a --name=b` preserves insertion order and is read + // back through Input.Flags, never Input.Flag. Existing scalar/bare + // flags are unaffected: Repeatable defaults to false. + Repeatable bool } // Arg describes a positional argument. @@ -42,6 +49,10 @@ type Input interface { Args() []string Argument(name string) (string, bool) Flag(name string) (string, bool) + // Flags returns the ordered, repeated values of a Repeatable flag + // (D-19). It returns nil for a scalar/bare flag or an unset repeatable + // flag; callers must use Flag for scalar flags. + Flags(name string) []string } type cobraInput struct { @@ -64,6 +75,20 @@ func (in cobraInput) Argument(name string) (string, bool) { return "", false } +// Flags returns the ordered values of a Repeatable (Cobra StringSlice) +// flag. It returns nil if the flag was never registered as repeatable +// (wrong pflag type) or the command has no flag set (08-CONTEXT.md D-19). +func (in cobraInput) Flags(name string) []string { + if in.cmd == nil || in.cmd.Flags() == nil { + return nil + } + vals, err := in.cmd.Flags().GetStringSlice(name) + if err != nil { + return nil + } + return vals +} + func (in cobraInput) Flag(name string) (string, bool) { if in.cmd == nil || in.cmd.Flags() == nil { return "", false diff --git a/bonfire/output_test.go b/bonfire/output_test.go index 8a08a19..5e7d5f8 100644 --- a/bonfire/output_test.go +++ b/bonfire/output_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "errors" + "slices" "strings" "testing" ) @@ -244,6 +245,61 @@ func TestNonTTYProgressSkipsIntraDecile(t *testing.T) { } } +// TestPhase8RedBonfireFlags is the Phase 8 Wave 7 RED anchor (08-07-PLAN.md +// Task 1, D-19). bonfire.Flag/Input are scalar-only today +// (08-PATTERNS.md "bonfire repeatable-flag gap"): a Repeatable flag +// registered through Command.Flags must preserve every repeated +// `--name=value` occurrence in order via Input.Flags, while an ordinary +// scalar flag declared alongside it keeps working through Input.Flag +// unmodified. It fails with the PHASE8_RED:bonfire-flags sentinel while +// Repeatable is not yet wired into Cobra registration; +// scripts/check-phase8-red.sh verifies this failure is fail-closed. +func TestPhase8RedBonfireFlags(t *testing.T) { + var buf bytes.Buffer + var gotRedirect, gotScope []string + var gotMode string + var gotModeOK bool + root, err := NewRootIO("app", []Command{{ + Name: "demo:repeat", + Description: "Demo repeatable flags", + Flags: []Flag{ + {Name: "redirect-uri", Repeatable: true, Description: "repeatable redirect URI"}, + {Name: "scope", Repeatable: true, Description: "repeatable scope"}, + {Name: "mode", Default: "slow", Description: "scalar, unaffected by D-19"}, + }, + Run: func(ctx context.Context, in Input, out Output) error { + gotRedirect = in.Flags("redirect-uri") + gotScope = in.Flags("scope") + gotMode, gotModeOK = in.Flag("mode") + return nil + }, + }}, strings.NewReader(""), &buf, &buf) + if err != nil { + t.Fatal(err) + } + root.SetArgs([]string{ + "demo:repeat", + "--redirect-uri=https://a.example/callback", + "--redirect-uri=https://b.example/callback", + "--scope=read", + "--mode=fast", + }) + if err := root.Execute(); err != nil { + t.Fatal(err) + } + + wantRedirect := []string{"https://a.example/callback", "https://b.example/callback"} + if !slices.Equal(gotRedirect, wantRedirect) { + t.Fatalf("PHASE8_RED:bonfire-flags: repeated --redirect-uri via Input.Flags = %v, want %v in insertion order", gotRedirect, wantRedirect) + } + if !slices.Equal(gotScope, []string{"read"}) { + t.Fatalf("PHASE8_RED:bonfire-flags: repeated --scope via Input.Flags = %v, want [read]", gotScope) + } + if !gotModeOK || gotMode != "fast" { + t.Fatalf("PHASE8_RED:bonfire-flags: existing scalar --mode via Input.Flag = (%q, %v), want (\"fast\", true) unaffected by Repeatable", gotMode, gotModeOK) + } +} + func TestHelpUsesSharedAdapter(t *testing.T) { var buf bytes.Buffer root, err := NewRoot("hello", []Command{{