Skip to content

Go: a recipe option binds to its field's declared type - #8639

Draft
knutwannheden wants to merge 2 commits into
mainfrom
go-non-string-recipe-options-fail-to-bind
Draft

Go: a recipe option binds to its field's declared type#8639
knutwannheden wants to merge 2 commits into
mainfrom
go-non-string-recipe-options-fail-to-bind

Conversation

@knutwannheden

Copy link
Copy Markdown
Contributor

Setting any non-string option on any Go recipe from the Moderne CLI fails outright. mod run . --recipe=org.openrewrite.golang.AddImport -PpackagePath=strings -PonlyIfReferenced=false dies with Internal error: reflect.Set: value of type string is not assignable to type bool, for both true and false. Since -P is the only way to set recipe options from the CLI, every non-string option on every Go recipe is currently unreachable.

The mechanism

newReflectConstructor bound options onto the recipe struct with no conversion at all:

f := elem.FieldByName(fieldName)
if f.IsValid() && f.CanSet() {
    f.Set(reflect.ValueOf(val))   // dynamic type must already be assignable
}

val is whatever encoding/json decoded into map[string]any. The CLI declares -P as picocli Map<String, Object>, so onlyIfReferenced=false parses to the Java String "false", serialises as a JSON string, and lands as a Go string against a bool field. Fixing only string→bool would not have been enough: a JSON number decodes to float64, so an int field failed identically even for a caller sending a correctly typed number.

The fix

Values are coerced to the field's declared type before being set. The reference for what a recipe option may be written as is Jackson, not RecipeIntrospectionUtils.convertconvert only handles String targets and string→enum, and passes "false" straight through to a boolean parameter. Java tolerates the flag because constructRecipe throws and RecipeLoader falls back to mapper.convertValue. The closest in-repo peer is C# RewriteRpcServer.ConvertOptionValue, which deserialises the fragment into the declared property type.

Two further defects surfaced while matching that policy:

PrepareRecipe now decodes with UseNumber, as pkg/rpc/rpc_object_data.go already does. Without it an integer option past 2^53 is silently corrupted before the binder sees it — 9007199254740993 bound as 9007199254740992 with no error, since a truncation check only rejects non-integral floats.

Option names resolve to fields case-insensitively when the capitalised spelling misses, so url reaches a URL field as it does on the Java host (ACCEPT_CASE_INSENSITIVE_PROPERTIES) and in C# (BindingFlags.IgnoreCase). Previously it produced Url, missed, and dropped the value silently, leaving the recipe running with a zero-valued option.

An option naming no field stays ignored, matching Java (Jackson with FAIL_ON_UNKNOWN_PROPERTIES disabled) and C#. Python is the only peer that errors, and only as a side effect of recipe_class(**options).

Breaking change

RecipeConstructor gains an error return, going from func(map[string]any) Recipe to func(map[string]any) (Recipe, error). There is no additive way to do this in Go, and no error channel means no way to name the failing option — today it is a recovered panic reported as -32603 Internal error with no mention of which recipe or option. It is now an OptionBindError naming the recipe, option, declared type and value, returned as -32602.

The module is v0.0.31, pre-1.0. The only external consumer is moderneinc/recipes-go, which calls Constructor in exactly two test files, both passing nil options and so incapable of producing a bind error. The alternative — panicking with a typed error and recovering at the RPC boundary — would preserve compatibility but keeps the broken contract alive for anyone still calling the old signature, and uses panic as control flow for a user typing a bad flag value.

Tests

GolangRecipeIntegTest.booleanRecipeOptionArrivingAsAString runs the wire path the CLI uses, asserting that onlyIfReferenced bound from a JSON string takes effect in both directions. It is mutation-checked: with the coercion reverted it reproduces the reported message verbatim, reflect.Set: value of type string is not assignable to type bool. TestPrepareRecipeBindsOptionsFromWireJSON is likewise mutation-checked against the UseNumber decode, failing with expected 9007199254740993, actual 9007199254740992.

pkg/recipe/options_test.go covers the conversion matrix directly: string↔bool both ways, float64/string/json.Number to int and uint, width overflow, non-integral and out-of-int64-range floats, pointer and slice options, and unconvertible values.

Scope

pkg/visitor/init.go looked like it needed the same treatment and does not — both f.Set(reflect.ValueOf(v)) calls assign the visitor pointer to its Self field for virtual dispatch and never see wire data. VisitorOptions in cmd/rpc/main.go is decoded and then read nowhere in the repo.

Whether the CLI should also coerce Java-side, where it does know the declared type from the option descriptors, is left open. The Go binder has to be robust against whatever any RPC client sends either way.

The two recipes-go test call sites are tracked separately and can only be adapted after this lands, since the fix does not compile against the rewrite-go that repo currently resolves.

Every non-string option on every Go recipe was unreachable from the Moderne
CLI. `mod run --recipe=org.openrewrite.golang.AddImport -PonlyIfReferenced=false`
failed with `Internal error: reflect.Set: value of type string is not
assignable to type bool`, for both `true` and `false`.

The registry bound options by `f.Set(reflect.ValueOf(val))` with no
conversion. `val` is whatever `encoding/json` decoded into `map[string]any`,
and the CLI declares `-P` as picocli `Map<String, Object>`, so every
command-line option arrives as a string whatever its declared type. A JSON
number decodes to `float64`, so an `int` field failed identically even for a
correctly typed caller.

Values are now coerced to the field's declared type before being set,
mirroring Jackson (which the Java host reaches via RecipeLoader's
`convertValue` fallback, not `RecipeIntrospectionUtils.convert`) and the C#
server's `Convert.ChangeType`. An unconvertible value produces an
`OptionBindError` naming the recipe, option, declared type and value, which
`handlePrepareRecipe` returns as `-32602` rather than a recovered panic. This
requires `RecipeConstructor` to return an error.

`PrepareRecipe` decodes with `UseNumber` so an integer option past 2^53
survives: `9007199254740993` otherwise bound as `9007199254740992` with no
error at all.

Option names resolve to fields case-insensitively when the capitalized
spelling misses, so `url` reaches a `URL` field as it does on the Java host
(`ACCEPT_CASE_INSENSITIVE_PROPERTIES`) and in C# (`BindingFlags.IgnoreCase`).
An option naming no field stays ignored, matching Java (Jackson with
`FAIL_ON_UNKNOWN_PROPERTIES` disabled) and C#.

`GolangRecipeIntegTest` covers the wire path the CLI uses: with the coercion
reverted, `booleanRecipeOptionArrivingAsAString` reproduces the reported
`reflect.Set` message verbatim.
RecipeConstructor is exported and takes map[string]any, so options reach the
binder from two origins: JSON-decoded wire values (string, json.Number) and
Go values passed directly in-process. Only the first was accepted, so
`Constructor(map[string]any{"count": 42})` bound to an `int` field via the
assignable fast path but failed against `int64`, `uint64`, `float64` or
`string` — the same value that binds when it arrives as json.Number("42").

The conversion helpers now read any integer, unsigned or float kind through
reflection, range-checked as before.

Also corrects two comments. Register's doc claimed "for recipes without
options, the prototype itself is returned", which newReflectConstructor has
never done — it allocates a zero-valued instance, discarding any field set on
the prototype. And coerceOption claimed its conversions "mirror" Jackson and
Convert.ChangeType, which both read a numeric 1/0 as a bool where this does
not; the deviation is now stated at the case it applies to.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

1 participant