From a7707365766ef6bbab28bdc6d6970c9b4979899f Mon Sep 17 00:00:00 2001 From: Andreas Trawoeger Date: Sat, 24 Oct 2026 16:43:51 +0200 Subject: [PATCH] feat(command): give a `command:` step the org's "the phase ran nothing" channel (plugin-command#10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The org already owns the vocabulary for this state — `sdk.CheckSkippedExitCode = 3` ("the phase RAN NOTHING — neither a pass nor a failure", spec/exitcode) — and plugin-check's bed runner already honours it (candy/plugin-check/no_plan_skip.go:52). The `command:` verb was the one place the convention could not reach: its exit-code contract is {0, exit_status} plus the `expect_non_zero` arm, so "the phase ran nothing" had no representation and was rounded into "failed". `#CommandInput` (the plugin's OWN schema, plugin-exclusive by construction) gains `skip_exit?: int`. A command that exits the declared code is SKIPPED — neither a pass nor a failure — and the stdout/stderr matchers do not run for it, because a step that ran nothing has no output to assert on. The declaration is validated, not trusted, and checked BEFORE the command runs: only the org's code is accepted (a private code is refused with the org's named), a declaration contradicting the step's own `exit_status` is refused rather than silently overriding it, and `background: true` is refused (no exit status to interpret). The shared `#Op` `exit_status` matcher is NOT overloaded. The precedence is stated, not left to be discovered: the skip is read FIRST, so a step declaring skip_exit together with expect_non_zero means "exit this code → skipped; any other non-zero → passed" — a wildcard narrowed by an exact exception. Only an EXACT-code contradiction (skip_exit and exit_status naming the SAME code) is refused, because one code cannot be both the pass and the skip. It is OPT-IN, deliberately — a deviation from opencharly/plugin-task#35's unconditional wording, stated in the issue and the PR body. The unconditional rule would swallow two existing assertions (`exit_status: 3` is a PASS expectation, `expect_non_zero: true` is a "must fail" expectation), and the exit code belongs to the step's AUTHOR: a `command:` step runs an arbitrary shell script, not a charly-driven phase, so an undeclared 3 is an authoring error, not a phase report. The proof: ten verb tests (a skip is a skip; the SAME code without the declaration is still a FAILURE; a private code, a contradicting `exit_status` and `background` are refused; the matchers are short-circuited; no stdout is carried; the expect_non_zero precedence), two of them against a REAL process through the host-side os/exec leg, and a schema test that compiles the plugin's embedded `#CommandInput` and validates real inputs against it (the acceptance case is RED without the schema field; the typo'd-field case proves the def is closed, so the acceptance case is not a tautology). The generated params are what the schema produces — `charly candy params plugin-command --check` reports the file current. The assembled R10 bed lands in the consumer leg (opencharly/charly's pin bump), because the verb is COMPILED-IN. Assisted-by: Claude Code minimax/minimax-m3.1-flash:cloud (analysed on a live system) --- candy/plugin-command/go.mod | 2 +- candy/plugin-command/params/cue_types_gen.go | 23 +++ candy/plugin-command/plugin.go | 45 +++++ candy/plugin-command/plugin_test.go | 176 +++++++++++++++++++ candy/plugin-command/schema/command.cue | 22 +++ candy/plugin-command/schema_test.go | 103 +++++++++++ 6 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 candy/plugin-command/schema_test.go diff --git a/candy/plugin-command/go.mod b/candy/plugin-command/go.mod index 2158a20..1548237 100644 --- a/candy/plugin-command/go.mod +++ b/candy/plugin-command/go.mod @@ -5,7 +5,7 @@ go 1.26.4 require github.com/opencharly/sdk v0.2026234.347 require ( - cuelang.org/go v0.16.1 // indirect + cuelang.org/go v0.16.1 github.com/alecthomas/kong v1.15.0 // indirect github.com/cockroachdb/apd/v3 v3.2.1 // indirect github.com/emicklei/proto v1.14.3 // indirect diff --git a/candy/plugin-command/params/cue_types_gen.go b/candy/plugin-command/params/cue_types_gen.go index a52047f..2a875a9 100644 --- a/candy/plugin-command/params/cue_types_gen.go +++ b/candy/plugin-command/params/cue_types_gen.go @@ -56,4 +56,27 @@ type CommandInput struct { // mutually-exclusive intents: exact-code vs any-non-zero). Default false = // the historic behaviour (assert exit_status, defaulting to 0). ExpectNonZero bool `yaml:"expect_non_zero,omitempty" json:"expect_non_zero,omitempty"` + + // skip_exit — declare that THIS step's command reports "the phase ran nothing" + // by exiting with the org's skip code: the check 0/1/2/3 convention's 3 + // (github.com/opencharly/spec/exitcode — "the phase RAN NOTHING — neither a + // pass nor a failure"), re-exported as sdk.CheckSkippedExitCode. Only that + // code is accepted: a step declares the ORG's code, never a private one, so + // "3" means the same thing in a task step as it does in the check runner. + // + // A command that exits this code is SKIPPED, not failed: it proved nothing and + // disproved nothing, so it must mint neither a red nor a green. The + // stdout/stderr matchers do NOT run for a skipped step — a step that ran + // nothing has no output to assert on. + // + // OPT-IN, and it stays opt-in: a command that exits 3 WITHOUT this declaration + // is a command whose exit code the author never interpreted, and reading it as + // a skip would silently turn a real failure into a non-failure. + // + // PRECEDENCE: the skip is read FIRST. A step that declares skip_exit together + // with expect_non_zero therefore means "exit this code → skipped; any other + // non-zero → passed" — a wildcard narrowed by an exact exception. Only an + // EXACT-code contradiction is refused (skip_exit and exit_status naming the + // SAME code), because one code cannot be both the pass and the skip. + SkipExit *int `yaml:"skip_exit,omitempty" json:"skip_exit,omitempty"` } diff --git a/candy/plugin-command/plugin.go b/candy/plugin-command/plugin.go index 3ba9fd9..c02fda6 100644 --- a/candy/plugin-command/plugin.go +++ b/candy/plugin-command/plugin.go @@ -70,6 +70,35 @@ func (verb) RunVerb(ctx context.Context, cc kit.CheckContext, op *spec.Op) kit.R inContainer = false } + // skip_exit — the author declares that this command reports "the phase ran + // nothing" with the org's skip code (spec/exitcode: "the phase RAN NOTHING — + // neither a pass nor a failure"), so the step is SKIPPED instead of failed. + // The declaration is VALIDATED, not trusted: + // + // - only the org's code is accepted: a step that invented a private code + // would make "3" mean two different things across the org; + // - a declaration that contradicts the step's own exit_status assertion is + // refused rather than silently overriding it — a step cannot both PASS + // and SKIP on one exit code; + // - a backgrounded command has no exit status to interpret, so a skip code + // declared there would be silently inert. + // + // The declaration is checked BEFORE the command runs, so an authoring error + // never runs a probe. + if in.SkipExit != nil { + if *in.SkipExit != sdk.CheckSkippedExitCode { + return kit.Failf("skip_exit=%d is not the org's skip code (%d) — a step declares the org's code, never a private one", + *in.SkipExit, sdk.CheckSkippedExitCode) + } + if op.ExitStatus != nil && *op.ExitStatus == *in.SkipExit { + return kit.Failf("skip_exit and exit_status both declare %d — the step cannot both PASS and SKIP on one exit code", + *in.SkipExit) + } + if in.Background { + return kit.Failf("skip_exit with background: true — a backgrounded command has no exit status to interpret") + } + } + // The step's env: modifier (a base #Op field) is applied to the executed // command as shell exports. RCA: the field was schema-declared but no executor // path ever read it — the command ran with the container's default env, so an @@ -134,6 +163,22 @@ func (verb) RunVerb(ctx context.Context, cc kit.CheckContext, op *spec.Op) kit.R elapsed.Round(time.Millisecond), trimPreview(in.Command)) } + // The skip code — the ONE channel a step has for saying "the phase ran + // nothing". A command that exits it neither proved nor disproved its + // assertion, so the step is SKIPPED: it must mint neither a red nor a green. + // It is read BEFORE the exit comparison (an exit the author declared as a + // skip is not a mismatch), and the stdout/stderr matchers do not run for it — + // a step that ran nothing has no output to assert on. + // + // PRECEDENCE, and it is the same one the schema states: the skip wins over + // expect_non_zero, so a step declaring both means "exit this code → skipped; + // any other non-zero → passed" (a wildcard narrowed by an exact exception). + // The exact-code contradiction (skip_exit and exit_status naming the SAME + // code) is refused above, before the command runs. + if in.SkipExit != nil && exitCode == *in.SkipExit { + return kit.Skipf("exit=%d — the step declared this code as 'the phase ran nothing' (skip_exit); it is neither a pass nor a failure", exitCode) + } + // expect_non_zero asserts the command FAILED (any non-zero code) and IGNORES // exit_status — the two are mutually-exclusive intents (any-non-zero vs // exact-code). Otherwise assert the exact code (exit_status, default 0). diff --git a/candy/plugin-command/plugin_test.go b/candy/plugin-command/plugin_test.go index ea73292..d158b83 100644 --- a/candy/plugin-command/plugin_test.go +++ b/candy/plugin-command/plugin_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/opencharly/sdk" "github.com/opencharly/sdk/kit" "github.com/opencharly/spec/spec" ) @@ -326,3 +327,178 @@ func TestCommandVerb_StdoutBelowCapUntruncated(t *testing.T) { t.Errorf("message = %q, want no truncation note for under-cap output", res.Message) } } + +// --- the skip channel: skip_exit --------------------------------------------- +// +// The channel exists so a command can say "the phase ran nothing" with the org's +// OWN exit code instead of a private one. The org's code is +// sdk.CheckSkippedExitCode (spec/exitcode: "the phase RAN NOTHING — neither a +// pass nor a failure") — it is read from the contract here, never written as a +// literal, because a second statement of the contract is a second contract (R2). + +// TestCommandVerb_SkipExitIsSkipped proves the one thing the channel exists for: +// a command that exits the org's skip code, on a step that DECLARES it, is +// SKIPPED — neither a pass nor a failure. +func TestCommandVerb_SkipExitIsSkipped(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := runCommandVerb(code, map[string]any{"command": "exit 3", "skip_exit": code}, nil) + if res.Status != kit.StatusSkip { + t.Fatalf("status = %v, want skip — a step that declared skip_exit=%d ran nothing, so it is neither a pass nor a failure: %s", + res.Status, code, res.Message) + } + if !strings.Contains(res.Message, fmt.Sprintf("exit=%d", code)) { + t.Errorf("message = %q, want it to name the exit code the step ran out with", res.Message) + } +} + +// TestCommandVerb_SkipExitIsOptIn is the NEGATIVE CONTROL, and the reason the +// channel is opt-in: the SAME exit code on a step that does NOT declare it is +// still a FAILURE. A command's exit code is the author's to interpret; reading +// an undeclared 3 as a skip would turn a real failure into a non-failure. +func TestCommandVerb_SkipExitIsOptIn(t *testing.T) { + res := runCommandVerb(sdk.CheckSkippedExitCode, map[string]any{"command": "exit 3"}, nil) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — an undeclared exit code is the author's to read, not the verb's", res.Status) + } + if !strings.Contains(res.Message, fmt.Sprintf("exit=%d", sdk.CheckSkippedExitCode)) { + t.Errorf("message = %q, want the actual exit code", res.Message) + } +} + +// TestCommandVerb_SkipExitDoesNotSwallowOtherFailures proves the channel skips +// EXACTLY the declared code: a step that declares skip_exit=3 and whose command +// exits 1 is still a FAILURE. +func TestCommandVerb_SkipExitDoesNotSwallowOtherFailures(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := runCommandVerb(1, map[string]any{"command": "exit 1", "skip_exit": code}, nil) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — only the DECLARED code is a skip", res.Status) + } +} + +// TestCommandVerb_SkipExitRejectsAPrivateCode proves the declaration is +// validated, not trusted: a step that invents its own skip code is refused with +// a message that names the org's code, so "3" cannot mean two things. +func TestCommandVerb_SkipExitRejectsAPrivateCode(t *testing.T) { + res := runCommandVerb(0, map[string]any{"command": "true", "skip_exit": 7}, nil) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — a private skip code is an authoring error, not a channel", res.Status) + } + for _, want := range []string{"skip_exit=7", fmt.Sprintf("%d", sdk.CheckSkippedExitCode)} { + if !strings.Contains(res.Message, want) { + t.Errorf("message = %q, want it to name %q so the cause is readable without guessing", res.Message, want) + } + } +} + +// TestCommandVerb_SkipExitContradictingExitStatusIsRefused proves a step cannot +// both PASS and SKIP on one exit code: the contradiction is refused loudly +// rather than resolved silently in favour of either reading. +func TestCommandVerb_SkipExitContradictingExitStatusIsRefused(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := runCommandVerb(code, map[string]any{"command": "exit 3", "skip_exit": code}, &code) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — skip_exit and exit_status both declare %d", res.Status, code) + } + if !strings.Contains(res.Message, "exit_status") { + t.Errorf("message = %q, want it to name the contradiction", res.Message) + } +} + +// TestCommandVerb_SkipExitWithBackgroundIsRefused proves a declaration that +// could never take effect is refused instead of being silently inert: a +// backgrounded command has no exit status to interpret. +func TestCommandVerb_SkipExitWithBackgroundIsRefused(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := runCommandVerb(0, map[string]any{"command": "true", "skip_exit": code, "background": true}, nil) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — background: true has no exit status to skip on", res.Status) + } + if !strings.Contains(res.Message, "background") { + t.Errorf("message = %q, want it to name the contradiction", res.Message) + } +} + +// TestCommandVerb_SkipExitShortCircuitsTheMatchers proves a skipped step's +// stdout/stderr matchers do NOT run: a step that ran nothing has no output to +// assert on, and a matcher that turned the skip back into a failure would +// defeat the channel. The command's stdout is deliberately EMPTY here, which +// no `contains` matcher could accept. +func TestCommandVerb_SkipExitShortCircuitsTheMatchers(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := verb{}.RunVerb(context.Background(), &fakeCC{mode: kit.ModeLive, exec: &fakeExec{stdout: "", exit: code}}, + &spec.Op{PluginInput: map[string]any{"command": "exit 3", "skip_exit": code}, + Stdout: spec.MatcherList{{Op: "contains", Value: "never printed"}}}) + if res.Status != kit.StatusSkip { + t.Fatalf("status = %v, want skip — a step that ran nothing has no output to assert on: %s", res.Status, res.Message) + } +} + +// TestCommandVerb_SkipExitCarriesNoStdout proves a SKIP stashes no value: like +// a failure, a skipped step's output was never vouched for, and a consumer that +// read it would be acting on a value the step itself declined to assert. +func TestCommandVerb_SkipExitCarriesNoStdout(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := runCommandVerbOut("output of a step that ran nothing\n", code, map[string]any{"command": "exit 3", "skip_exit": code}) + if res.Status != kit.StatusSkip { + t.Fatalf("status = %v, want skip", res.Status) + } + if res.CapturedValue != "" { + t.Fatalf("CapturedValue = %q, want empty — a skip must not stash output", res.CapturedValue) + } +} + +// --- the skip channel against a REAL process --------------------------------- +// +// The tests above drive the verb through a canned exit code, which is the file's +// established fixture (and the right shape for the verb's branches). But the +// boundary this verb actually crosses is a PROCESS: the step's command is a shell +// command, and its exit code is what the channel reads. R8 says a boundary that +// crosses a real service runs against that service and is never mocked, so the +// channel is proven here against a real `sh -c` that really exits with the org's +// skip code — the host-side leg (from_host: true) executes through os/exec, so +// no executor is involved at all. + +// TestCommandVerb_SkipExitIsSkipped_Live proves the channel against a real +// process: a command that really exits 3, on a step that declares the code, is +// SKIPPED. +func TestCommandVerb_SkipExitIsSkipped_Live(t *testing.T) { + code := sdk.CheckSkippedExitCode + res := verb{}.RunVerb(context.Background(), &fakeCC{mode: kit.ModeLive}, + &spec.Op{PluginInput: map[string]any{"command": "exit 3", "from_host": true, "skip_exit": code}}) + if res.Status != kit.StatusSkip { + t.Fatalf("status = %v, want skip — a real command that exited %d, on a step that declared it, ran nothing: %s", + res.Status, code, res.Message) + } +} + +// TestCommandVerb_SkipExitIsOptIn_Live is the live NEGATIVE CONTROL: the SAME +// real command, on a step that does NOT declare the code, is still a FAILURE. +// The fake-exec twin of this case is TestCommandVerb_SkipExitIsOptIn; this one +// removes the mock from the comparison, so the opt-in property is proven against +// a process rather than a canned status. +func TestCommandVerb_SkipExitIsOptIn_Live(t *testing.T) { + res := verb{}.RunVerb(context.Background(), &fakeCC{mode: kit.ModeLive}, + &spec.Op{PluginInput: map[string]any{"command": "exit 3", "from_host": true}}) + if res.Status != kit.StatusFail { + t.Fatalf("status = %v, want fail — an undeclared exit code is the author's to read, not the verb's", res.Status) + } +} + +// TestCommandVerb_SkipExitPrecedenceOverExpectNonZero proves the precedence the +// schema states: the skip is read FIRST, so a step declaring skip_exit together +// with expect_non_zero means "exit this code → skipped; any other non-zero → +// passed" — a wildcard narrowed by an exact exception, not a contradiction. +// Only an EXACT-code contradiction (skip_exit and exit_status naming the same +// code) is refused, and that is proven by the test above. +func TestCommandVerb_SkipExitPrecedenceOverExpectNonZero(t *testing.T) { + code := sdk.CheckSkippedExitCode + skipped := runCommandVerb(code, map[string]any{"command": "exit 3", "skip_exit": code, "expect_non_zero": true}, nil) + if skipped.Status != kit.StatusSkip { + t.Fatalf("status = %v, want skip — the skip is read before the exit comparison: %s", skipped.Status, skipped.Message) + } + passed := runCommandVerb(1, map[string]any{"command": "exit 1", "skip_exit": code, "expect_non_zero": true}, nil) + if passed.Status != kit.StatusPass { + t.Fatalf("status = %v, want pass — any OTHER non-zero still satisfies expect_non_zero: %s", passed.Status, passed.Message) + } +} diff --git a/candy/plugin-command/schema/command.cue b/candy/plugin-command/schema/command.cue index 15faa0e..a8db8c1 100644 --- a/candy/plugin-command/schema/command.cue +++ b/candy/plugin-command/schema/command.cue @@ -48,4 +48,26 @@ // mutually-exclusive intents: exact-code vs any-non-zero). Default false = // the historic behaviour (assert exit_status, defaulting to 0). expect_non_zero?: bool @go(ExpectNonZero) + // skip_exit — declare that THIS step's command reports "the phase ran nothing" + // by exiting with the org's skip code: the check 0/1/2/3 convention's 3 + // (github.com/opencharly/spec/exitcode — "the phase RAN NOTHING — neither a + // pass nor a failure"), re-exported as sdk.CheckSkippedExitCode. Only that + // code is accepted: a step declares the ORG's code, never a private one, so + // "3" means the same thing in a task step as it does in the check runner. + // + // A command that exits this code is SKIPPED, not failed: it proved nothing and + // disproved nothing, so it must mint neither a red nor a green. The + // stdout/stderr matchers do NOT run for a skipped step — a step that ran + // nothing has no output to assert on. + // + // OPT-IN, and it stays opt-in: a command that exits 3 WITHOUT this declaration + // is a command whose exit code the author never interpreted, and reading it as + // a skip would silently turn a real failure into a non-failure. + // + // PRECEDENCE: the skip is read FIRST. A step that declares skip_exit together + // with expect_non_zero therefore means "exit this code → skipped; any other + // non-zero → passed" — a wildcard narrowed by an exact exception. Only an + // EXACT-code contradiction is refused (skip_exit and exit_status naming the + // SAME code), because one code cannot be both the pass and the skip. + skip_exit?: int @go(SkipExit,type=*int) } diff --git a/candy/plugin-command/schema_test.go b/candy/plugin-command/schema_test.go new file mode 100644 index 0000000..54d2100 --- /dev/null +++ b/candy/plugin-command/schema_test.go @@ -0,0 +1,103 @@ +package command + +import ( + "testing" + + "cuelang.org/go/cue" + "cuelang.org/go/cue/cuecontext" +) + +// The plugin's OWN schema, validated against the input the verb actually decodes. +// +// The runtime gate for a plugin_input lives in charly core +// (validateAuthoredPluginInput: unify the authored JSON with the served def, then +// Validate(cue.Concrete(true))), and it runs at DISPATCH — a step's plugin_input is +// only checked when the verb is invoked, never by `charly box validate` on the +// manifest. That leaves a hole on THIS side of the boundary: the schema a plugin +// ships and the plugin_input shape the plugin decodes can drift apart, and the +// drift is only discovered when a step runs. +// +// #CommandInput is SELF-CONTAINED (every field is a bare primitive referencing no +// base def — the schema header states this), so it compiles standalone and can be +// validated here against the plugin's own embedded source, with no base and no +// charly binary. The def is a CUE DEFINITION, so it is closed: an unknown field is +// rejected — which is what makes the "accepts skip_exit" case a real assertion +// rather than a tautology. + +// commandInputDef compiles the plugin's embedded schema and returns its +// #CommandInput definition, the exact def the runtime gate unifies authored +// plugin_input against. +func commandInputDef(t *testing.T) cue.Value { + t.Helper() + src, err := schemaFS.ReadFile("schema/command.cue") + if err != nil { + t.Fatalf("read embedded schema: %v", err) + } + ctx := cuecontext.New() + v := ctx.CompileBytes(src, cue.Filename("schema/command.cue")) + if v.Err() != nil { + t.Fatalf("compile schema: %v", v.Err()) + } + def := v.LookupPath(cue.ParsePath("#CommandInput")) + if def.Err() != nil { + t.Fatalf("lookup #CommandInput: %v", def.Err()) + } + return def +} + +// validateCommandInput mirrors charly core's validateAuthoredPluginInput: unify +// the authored JSON with the def, then validate requiring concreteness. +func validateCommandInput(t *testing.T, def cue.Value, inputJSON string) error { + t.Helper() + ctx := cuecontext.New() + in := ctx.CompileString(inputJSON) + if in.Err() != nil { + t.Fatalf("compile input: %v", in.Err()) + } + return def.Unify(in).Validate(cue.Concrete(true)) +} + +// TestCommandInputSchema_AcceptsSkipExit proves the shipped schema carries the +// skip_exit field the verb reads. Without the schema change the unify fails with +// "field not allowed", so this test can fail. +func TestCommandInputSchema_AcceptsSkipExit(t *testing.T) { + def := commandInputDef(t) + for _, input := range []string{ + `{"command":"true","skip_exit":3}`, + `{"command":"scripts/check-foo.mjs","skip_exit":3,"from_host":true}`, + `{"command":"true"}`, + } { + if err := validateCommandInput(t, def, input); err != nil { + t.Errorf("schema rejected valid input %s: %v", input, err) + } + } +} + +// TestCommandInputSchema_RejectsUnknownField is the NEGATIVE CONTROL for the +// acceptance test: the def is closed, so a typo'd field is refused. If this test +// ever passes with a typo'd field, the acceptance test above is a tautology and +// proves nothing. +func TestCommandInputSchema_RejectsUnknownField(t *testing.T) { + def := commandInputDef(t) + if err := validateCommandInput(t, def, `{"command":"true","skip_exi":3}`); err == nil { + t.Error("schema accepted an unknown field (skip_exi) — the def is not closed, so the acceptance test proves nothing") + } +} + +// TestCommandInputSchema_RejectsWrongType proves the field is typed as the org's +// integer exit code, not a string that would decode into nothing. +func TestCommandInputSchema_RejectsWrongType(t *testing.T) { + def := commandInputDef(t) + if err := validateCommandInput(t, def, `{"command":"true","skip_exit":"3"}`); err == nil { + t.Error("schema accepted skip_exit as a string") + } +} + +// TestCommandInputSchema_RequiresCommand proves the plugin's own required field +// still holds — the schema is not opened up by the new optional field. +func TestCommandInputSchema_RequiresCommand(t *testing.T) { + def := commandInputDef(t) + if err := validateCommandInput(t, def, `{"skip_exit":3}`); err == nil { + t.Error("schema accepted an input with no command") + } +}