From 4fb9283ce18f1c6e4db57934af497c576eda2d03 Mon Sep 17 00:00:00 2001 From: Antoine Toussaint Date: Sun, 13 Sep 2026 14:33:05 -0400 Subject: [PATCH] fix: stop the generated bindings from encoding states the contract forbids MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generated types carried the contract's optional and nullable declarations but left three states encodable that the declaration does not have, each of them silently. Optional[T] exported Present and Value, so Optional[string]{Value: &s} — the natural literal — set a value without marking it present, IsZero reported absent, and omitzero dropped the key from the payload with no error. The fields are now unexported and the states reachable only through Absent, Null and Set, so no literal can spell a state the contract does not have. MarshalJSON on an absent value is an error rather than a null: absence is omitted with its key, and reaching the marshaler absent means it is being encoded where a key cannot be omitted, where null would stand in for absent without saying so. An array was a Go slice, and a nil slice encodes as null. The scaffold returns a zero Output, so the first payload a generated runnable produced wrote null for a required array; decoding {} and re-encoding it turned an absent array into an explicit null; and a null arriving for a non-nullable array decoded without error. Arrays are now a List, which encodes an unset array as the empty array it is and refuses to decode a null one. A nullable array is a *List, where nil is the null state. An optional array is a Present[List[T]]: Present, which #4 added on main while this branch was open, refuses the null that a pointer around a List would have swallowed before the List ever saw it. omitzero is not gated by the module's go directive, only by the toolchain, so an older one ignored it in silence and turned every absent optional key into a null. Generated files now carry //go:build go1.24, which fails the build instead. Alongside, at the same seams: - exported() could return a non-identifier: core allows the field name _1, which generated the Go field 1 and surfaced as a gofmt parse error pointing into a file the author never sees. Names are checked against an exported-identifier pattern and the error names the contract field. - A type-name collision reported only the generated name, which is not a name anyone wrote. The generator now carries each type's contract path and reports both (input.a_b and input.a.b). - pkg and the runnable name were interpolated into source unvalidated. A runnable name is a path component, so word-count reached gofmt as a parse error and a newline would have injected top-level Go into a file marked DO NOT EDIT. Both are validated at the entry points. - Handler's doc promised the scaffold is "never rewritten over their implementation" while the package had no writer to keep that promise. WriteHandler creates the file with O_EXCL and reports whether it wrote one, so a regeneration cannot overwrite an author's handler. - Bindings takes the contract, not the Runnable, deliberately: Runnable.Validate also demands a pinned agent, entrypoint and execution block, none of which change a byte of output. Recorded at the signature. - The compile test hardcoded the go directive next to go.mod's; it now reads go.mod, so it cannot go on compiling at a version the repository has left. - Generated code is read as a diff by the author whose workspace it lands in, so a golden file pins the whole output: order, header, blank lines and the single emission of each carrier. - The naming rule is duplicated in runnable-python and belongs in core; it is pinned by a test against that implementation's names until it moves. - CI ran with the repository's default token scope and skipped pull requests that did not target main — with auto-merge-on-green, a stacked pull request with no checks reads as one that passed them. Added contents: read and dropped the branches filter. - docs/milestone_1_bindings_review.md recorded a review verdict ("found no blocking defect") as repository documentation; it cannot be kept true and this change disproves it. Removed, with the tracker narrative it duplicated in the README. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 7 +- README.md | 10 +- docs/milestone_1_bindings_review.md | 26 -- pkg/generate/bindings.go | 234 ++++++++++++++---- pkg/generate/bindings_test.go | 232 ++++++++++++++++- pkg/generate/compile_test.go | 141 +++++++++-- pkg/generate/handler.go | 60 ++++- .../testdata/covering_bindings.golden | 188 ++++++++++++++ 8 files changed, 787 insertions(+), 111 deletions(-) delete mode 100644 docs/milestone_1_bindings_review.md create mode 100644 pkg/generate/testdata/covering_bindings.golden diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 07eeb51..ac8a62a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,11 +2,16 @@ name: ci on: push: branches: [ main ] + # No branches filter: a pull request stacked on another branch runs the same + # checks. With auto-merge-on-green enabled, a pull request with no checks is + # indistinguishable from one that passed them. pull_request: - branches: [ main ] concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +# The job reads the tree and reports; it writes nothing back to the repository. +permissions: + contents: read jobs: build: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 02f606c..ca6d01d 100644 --- a/README.md +++ b/README.md @@ -2,15 +2,11 @@ The Codefly language agent for typed, finite Go operations: generate a handler and harness, compile portable native artifacts, and emit container build recipes. -**Status: the contract-determined generation slice is implemented; the agent process, harness and qualification are deferred until the Python/native/k3d contract is qualified.** No agent executable or release is available yet. [Implementation issue #1](https://github.com/codefly-dev/runnable-go/issues/1) records the original scope; [issue #2](https://github.com/codefly-dev/runnable-go/issues/2) tracks the remaining execution lifecycle. +**Status: the contract-determined generation slice is implemented. No agent executable or release is available yet.** [Issue #2](https://github.com/codefly-dev/runnable-go/issues/2) tracks the remaining execution lifecycle: the agent process, the invocation harness, build evidence and native/k3d qualification. -`pkg/generate` turns a runnable's declared contract into the typed Go bindings and the author handler scaffold. The bounded profile maps to `string`, `int64`, `bool`, generated structs and slices, and the contract's independent `optional` (the key may be absent) and `nullable` (the value may be null) declarations map to four distinct Go spellings, one per combination, so neither state can stand for the other. +`pkg/generate` turns a runnable's declared contract into the typed Go bindings and the author handler scaffold. The bounded profile maps to `string`, `int64`, `bool`, generated structs and `List`, and the contract's independent `optional` (the key may be absent) and `nullable` (the value may be null) declarations map to four distinct Go spellings, one per combination, so neither state can stand for the other. Generated files carry a `//go:build go1.24` constraint: absence is kept by the `encoding/json` `omitzero` option, which an older toolchain ignores in silence rather than rejecting. -The agent gRPC process, the invocation harness, build evidence and native/k3d qualification wait on [core #472](https://github.com/codefly-dev/core/issues/472), which freezes the agent/CLI handoff `runnable.codefly.yaml` names but does not define: carrying a `RunnableIdentity` through `Builder.Load`, returning native launch and build evidence from `Builder.Package`, and the `codefly.runnable/v1` framing a harness must agree with byte for byte. - -Python now has a harness and packaging implementation in [runnable-python #2](https://github.com/codefly-dev/runnable-python/pull/2). Its framing remains a proposal for core #472. Go must consume the shared contract after it is ratified and qualified through the real Codefly/Orchestration path. - -Bindings do not replace payload validation: the future harness must enforce required fields, unknown-key rejection, and non-null arrays before decoding input or accepting output. See [the review milestone](docs/milestone_1_bindings_review.md). +Bindings do not replace payload validation. The generated types carry the value types, the null states and the array states and nothing more, so the harness must still enforce required fields and reject unknown keys against the raw payload before decoding input or accepting output. ## What belongs here diff --git a/docs/milestone_1_bindings_review.md b/docs/milestone_1_bindings_review.md deleted file mode 100644 index 0454ac9..0000000 --- a/docs/milestone_1_bindings_review.md +++ /dev/null @@ -1,26 +0,0 @@ -# Milestone 1 — reviewed Go bindings - -PR #3 establishes the `codefly:runnable` / `go` distribution identity and renders -core's bounded Runnable schema as typed Go input/output bindings and an author -handler scaffold. Core is pinned at `6a40c4bf28ac`. - -The review found no blocking defect in this limited implementation. Go tests -compile the generated package with the real compiler and `encoding/json`, -checking absent/null/set states, signed 64-bit integers, type mismatches and -identifier collisions. The repository's tests, build and vet must pass before -merge. - -This milestone does not include a runnable agent executable, invocation harness, -native package, image recipe, release or durable invocation qualification. -[Issue #2](https://github.com/codefly-dev/runnable-go/issues/2) carries the -remaining acceptance checklist from issue #1. - -Python now has a proposed harness and packaging implementation. Core #472 must -ratify the shared loading, build-evidence and invocation contracts; Go will reuse -that contract after Python qualification. No Go-specific launcher protocol or -language branch belongs in CLI or Orchestration. - -The future Go harness must validate required fields, unknown keys and nullable -arrays against the raw payload. Generated Go types alone do not enforce those -rules. Native and Orchestration-created k3d invocation Jobs, cross-language -agreement, immutable version selection and reconnect remain to qualify. diff --git a/pkg/generate/bindings.go b/pkg/generate/bindings.go index 013f878..b8b6fef 100644 --- a/pkg/generate/bindings.go +++ b/pkg/generate/bindings.go @@ -6,22 +6,54 @@ package generate import ( "fmt" "go/format" + "go/token" + "regexp" "strings" "github.com/codefly-dev/core/resources" ) // Type names of a generated binding file: the two payload roots and the two -// carriers of a field the contract declares optional. +// carriers of a declaration no plain Go type expresses. const ( inputType = "Input" outputType = "Output" optionalType = "Optional" presentType = "Present" + listType = "List" ) +// GoRequirement is the toolchain floor of generated bindings, carried in each +// file as a build constraint. +// +// An absent key is omitted by the encoding/json omitzero option, which arrived +// in Go 1.24 and which an older toolchain ignores in silence rather than +// rejecting: every absent optional key would then encode as null, the one +// state the contract keeps distinct from absent. The go directive of the +// author's module does not gate the option, so the constraint is what makes an +// old toolchain fail to build instead of encoding the wrong payload. +const GoRequirement = "go1.24" + +// goExported matches the Go field and type names generation emits. encoding/json +// ignores an unexported field, so a contract field that generated one would be +// dropped from every payload rather than rejected; a name that is no +// identifier at all reaches gofmt as a parse error naming neither the field +// nor the reason. +var goExported = regexp.MustCompile(`^[A-Z][A-Za-z0-9_]*$`) + +// goIdentifier matches a package name generation may be asked to render into a +// file. A runnable name is a path component, so it may legally carry a dash +// that no Go package name may. +var goIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + // Bindings renders the typed Go bindings of a contract's input and output. // +// It takes the contract rather than the whole resources.Runnable because it +// validates exactly what it renders: Runnable.Validate additionally demands a +// pinned agent, an entrypoint and an execution block, none of which change a +// byte of the output, so taking the Runnable would reject declarations that +// generate perfectly well. +// // The bounded profile maps to Go by value type and by the two declarations the // contract keeps independent: optional means the key may be absent, nullable // means the value may be null. A field that is only optional is a Present, @@ -29,18 +61,24 @@ const ( // is only nullable is a pointer written as null, and a field that is both is an // Optional, which round-trips absent and null as the distinct states they are. // -// A Go nil slice encodes as null whether or not the array field is declared -// nullable, so an array the contract never allows to be null is caught by -// output validation rather than by its type. +// An array is a List and not a plain Go slice. A nil slice encodes as null, +// and only a field the contract declares nullable has a null state to encode, +// so a nil slice would put a value on the wire that the declaration forbids +// and that no decoder on the other side has to accept. A List encodes an unset +// array as the empty array it is and refuses to decode a null one; where the +// contract does declare the array nullable, the null state is the nil *List. func Bindings(pkg string, contract *resources.RunnableContract) ([]byte, error) { + if err := validatePackageName(pkg); err != nil { + return nil, err + } if err := contract.Validate(); err != nil { return nil, err } - g := &generator{declared: make(map[string]struct{})} - if err := g.enqueue(inputType, "is the typed invocation input declared by the contract.", contract.Input.Fields); err != nil { + g := &generator{declared: make(map[string]string)} + if err := g.enqueue(inputType, "input", "is the typed invocation input declared by the contract.", contract.Input.Fields); err != nil { return nil, err } - if err := g.enqueue(outputType, "is the typed invocation output declared by the contract.", contract.Output.Fields); err != nil { + if err := g.enqueue(outputType, "output", "is the typed invocation output declared by the contract.", contract.Output.Fields); err != nil { return nil, err } if err := g.run(); err != nil { @@ -48,13 +86,11 @@ func Bindings(pkg string, contract *resources.RunnableContract) ([]byte, error) } var b strings.Builder + fmt.Fprintf(&b, "//go:build %s\n\n", GoRequirement) b.WriteString("// Code generated by runnable-go. DO NOT EDIT.\n\n") fmt.Fprintf(&b, "package %s\n", pkg) - switch { - case g.usesPresent: + if g.usesCarrier() { b.WriteString("\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n)\n") - case g.usesOptional: - b.WriteString("\nimport \"encoding/json\"\n") } for _, decl := range g.decls { b.WriteString("\n") @@ -68,31 +104,58 @@ func Bindings(pkg string, contract *resources.RunnableContract) ([]byte, error) b.WriteString("\n") b.WriteString(presentDeclaration) } + if g.usesList { + b.WriteString("\n") + b.WriteString(listDeclaration) + } return format.Source([]byte(b.String())) } +// validatePackageName rejects a package name that would reach gofmt as a parse +// error, or that would inject whatever it carries into a file marked as +// generated. +func validatePackageName(pkg string) error { + if !goIdentifier.MatchString(pkg) || pkg == "_" || token.IsKeyword(pkg) { + return fmt.Errorf("package name %q is not a Go package identifier", pkg) + } + return nil +} + type pending struct { - name string + name string + // path is the contract path this type is generated from, such as + // input.options or input.records[]. It names the declaration an author can + // act on; the Go type name names nothing they wrote. + path string doc string fields []*resources.RunnableField } type generator struct { - decls []string - queue []pending - declared map[string]struct{} + decls []string + queue []pending + // declared maps a generated Go type name to the contract path that + // produced it, so a collision reports both paths. + declared map[string]string usesOptional bool usesPresent bool + usesList bool +} + +// usesCarrier reports whether the file declares Optional, Present or List, +// which are what the generated imports are for. +func (g *generator) usesCarrier() bool { + return g.usesOptional || g.usesPresent || g.usesList } // enqueue schedules one object type. Nested objects are rendered after the type // that references them, so a binding file reads from the payload root down. -func (g *generator) enqueue(name string, doc string, fields []*resources.RunnableField) error { - if _, exists := g.declared[name]; exists { - return fmt.Errorf("type %q is generated twice: rename the field it comes from", name) +func (g *generator) enqueue(name string, path string, doc string, fields []*resources.RunnableField) error { + if previous, exists := g.declared[name]; exists { + return fmt.Errorf("contract paths %s and %s both generate Go type %q: rename one of the fields", previous, path, name) } - g.declared[name] = struct{}{} - g.queue = append(g.queue, pending{name: name, doc: doc, fields: fields}) + g.declared[name] = path + g.queue = append(g.queue, pending{name: name, path: path, doc: doc, fields: fields}) return nil } @@ -116,16 +179,17 @@ func (g *generator) structure(p pending) (string, error) { taken := make(map[string]string, len(p.fields)) for _, field := range p.fields { + path := p.path + "." + field.Name name := exported(field.Name) - if name == "" { - return "", fmt.Errorf("field %q has no Go identifier", field.Name) + if !goExported.MatchString(name) { + return "", fmt.Errorf("contract field %s has no exported Go identifier: %q generates %q", path, field.Name, name) } if previous, clash := taken[name]; clash { return "", fmt.Errorf("fields %q and %q both generate Go field %q", previous, field.Name, name) } taken[name] = field.Name - declared, err := g.fieldType(p.name+name, field) + declared, err := g.fieldType(p.name+name, path, field) if err != nil { return "", err } @@ -141,9 +205,10 @@ func (g *generator) structure(p pending) (string, error) { } // fieldType is the Go type a field is declared with, optionality and -// nullability included. hint names the type a nested object is generated as. -func (g *generator) fieldType(hint string, field *resources.RunnableField) (string, error) { - base, err := g.valueType(hint, field) +// nullability included. hint names the type a nested object is generated as and +// path names the contract declaration it comes from. +func (g *generator) fieldType(hint string, path string, field *resources.RunnableField) (string, error) { + base, err := g.valueType(hint, path, field) if err != nil { return "", err } @@ -151,17 +216,23 @@ func (g *generator) fieldType(hint string, field *resources.RunnableField) (stri case field.Optional && field.Nullable: g.usesOptional = true return fmt.Sprintf("%s[%s]", optionalType, base), nil + case field.Nullable: + // The pointer is the null state: nil is written as null. + return "*" + base, nil case field.Optional: + // Present is the absent state, which omitzero omits. It is a value + // and not a pointer because encoding/json sets a pointer field to nil + // for an explicit null without consulting an unmarshaler, which would + // admit the null this field does not declare and leave it + // indistinguishable from the key being absent. g.usesPresent = true return fmt.Sprintf("%s[%s]", presentType, base), nil - case field.Nullable: - return "*" + base, nil default: return base, nil } } -func (g *generator) valueType(hint string, field *resources.RunnableField) (string, error) { +func (g *generator) valueType(hint string, path string, field *resources.RunnableField) (string, error) { switch field.Type { case resources.RunnableFieldString: return "string", nil @@ -170,21 +241,22 @@ func (g *generator) valueType(hint string, field *resources.RunnableField) (stri case resources.RunnableFieldBoolean: return "bool", nil case resources.RunnableFieldObject: - if err := g.enqueue(hint, "is a nested object of the contract.", field.Fields); err != nil { + if err := g.enqueue(hint, path, "is a nested object of the contract.", field.Fields); err != nil { return "", err } return hint, nil case resources.RunnableFieldArray: - element, err := g.valueType(hint+"Item", field.Items) + element, err := g.valueType(hint+"Item", path+"[]", field.Items) if err != nil { return "", err } if field.Items.Nullable { element = "*" + element } - return "[]" + element, nil + g.usesList = true + return fmt.Sprintf("%s[%s]", listType, element), nil } - return "", fmt.Errorf("field %q has type %q outside the bounded profile", field.Name, field.Type) + return "", fmt.Errorf("contract field %s has type %q outside the bounded profile", path, field.Type) } // jsonTag omits an optional key when it is absent; a nullable-only key is @@ -198,6 +270,12 @@ func jsonTag(field *resources.RunnableField) string { // exported turns a contract field name into a Go field name. Contract names are // identifiers, so the only transformation is underscore-separated capitalization. +// +// This rule is shared with the other runnable agents: an author reading the Go +// and the Python bindings of one contract must see the same names. It is +// duplicated in runnable-python pkg/generate/types.go (camel and nestedName) +// and belongs in core, which owns the shared protocol and types. +// TestNamingIsTheSharedCrossLanguageRule pins it until it moves there. func exported(name string) string { var b strings.Builder for _, part := range strings.Split(name, "_") { @@ -211,37 +289,105 @@ func exported(name string) string { } const optionalDeclaration = `// Optional carries a field the contract declares both optional and nullable. -// Absent and null are separate states there, so neither may stand for the other. +// Absent, null and set are three separate states there, so none may stand for +// another. +// +// The states are reachable only through Absent, Null and Set, and the zero +// Optional is absent, so no combination of fields can spell a state the +// contract does not have. type Optional[T any] struct { - // Present is true when the payload carried the key. - Present bool - // Value is nil when the key was present and null. - Value *T + present bool + value *T +} + +// Absent is the state of a key the payload does not carry. It is the zero +// Optional. +func Absent[T any]() Optional[T] { + return Optional[T]{} +} + +// Null is the state of a key the payload carries as null. +func Null[T any]() Optional[T] { + return Optional[T]{present: true} +} + +// Set is the state of a key the payload carries with a value. +func Set[T any](value T) Optional[T] { + return Optional[T]{present: true, value: &value} +} + +// Present reports whether the payload carried the key at all. +func (o Optional[T]) Present() bool { + return o.present +} + +// Get returns the carried value, and false when the key was absent or null. +func (o Optional[T]) Get() (T, bool) { + if o.value == nil { + var zero T + return zero, false + } + return *o.value, true } // IsZero reports an absent key, which is what omits it from an encoded payload. func (o Optional[T]) IsZero() bool { - return !o.Present + return !o.present } +// MarshalJSON encodes the null and the set state. The absent state has no +// encoding as a value: every generated field carries omitzero, so an absent +// Optional is omitted with its key and never reaches here. Reaching here +// absent means the value is being encoded somewhere a key cannot be omitted, +// where writing null would turn absent into null without saying so. func (o Optional[T]) MarshalJSON() ([]byte, error) { - if o.Value == nil { + if !o.present { + return nil, errors.New("an absent optional value has no encoding: it is omitted with its key, never written as null") + } + if o.value == nil { return []byte("null"), nil } - return json.Marshal(o.Value) + return json.Marshal(o.value) } func (o *Optional[T]) UnmarshalJSON(data []byte) error { - o.Present = true if string(data) == "null" { - o.Value = nil + o.present, o.value = true, nil return nil } var value T if err := json.Unmarshal(data, &value); err != nil { return err } - o.Value = &value + o.present, o.value = true, &value + return nil +} +` + +const listDeclaration = `// List carries an array of the contract. +// +// A nil Go slice encodes as null, and an array is null only where the contract +// declares the field nullable — a state spelled as the nil *List, never as a +// nil List. So an unset List encodes as the empty array it is, and a null +// payload is refused rather than decoded as an empty array. +type List[T any] []T + +func (l List[T]) MarshalJSON() ([]byte, error) { + if l == nil { + return []byte("[]"), nil + } + return json.Marshal([]T(l)) +} + +func (l *List[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return errors.New("array is null but the contract does not declare it nullable") + } + var values []T + if err := json.Unmarshal(data, &values); err != nil { + return err + } + *l = values return nil } ` diff --git a/pkg/generate/bindings_test.go b/pkg/generate/bindings_test.go index d0115a3..3a1b262 100644 --- a/pkg/generate/bindings_test.go +++ b/pkg/generate/bindings_test.go @@ -1,6 +1,8 @@ package generate_test import ( + "os" + "path/filepath" "strings" "testing" @@ -57,7 +59,7 @@ func TestValueTypesMapToTheBoundedProfile(t *testing.T) { requireLine(t, source, "Text string `json:\"text\"`") requireLine(t, source, "Count int64 `json:\"count\"`") requireLine(t, source, "Enabled bool `json:\"enabled\"`") - requireLine(t, source, "Tags []string `json:\"tags\"`") + requireLine(t, source, "Tags List[string] `json:\"tags\"`") requireLine(t, source, "Total int64 `json:\"total\"`") } @@ -108,7 +110,7 @@ func TestOptionalAndNotNullableIsNeverAPointer(t *testing.T) { requireLine(t, source, "Count Present[int64] `json:\"count,omitzero\"`") requireLine(t, source, "Enabled Present[bool] `json:\"enabled,omitzero\"`") requireLine(t, source, "Options Present[InputOptions] `json:\"options,omitzero\"`") - requireLine(t, source, "Tags Present[[]string] `json:\"tags,omitzero\"`") + requireLine(t, source, "Tags Present[List[string]] `json:\"tags,omitzero\"`") } // A carrier is only declared when the contract needs it. @@ -147,8 +149,8 @@ func TestNestedObjectsAreNamedByPath(t *testing.T) { ) requireLine(t, source, "Options Present[InputOptions] `json:\"options,omitzero\"`") - requireLine(t, source, "StopWords *[]string `json:\"stop_words\"`") - requireLine(t, source, "Records []InputRecordsItem `json:\"records\"`") + requireLine(t, source, "StopWords *List[string] `json:\"stop_words\"`") + requireLine(t, source, "Records List[InputRecordsItem] `json:\"records\"`") requireLine(t, source, "type InputRecordsItem struct {") requireLine(t, source, "type OutputOptions struct {") } @@ -161,7 +163,7 @@ func TestNullableArrayElements(t *testing.T) { }, []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, ) - requireLine(t, source, "Scores []*int64 `json:\"scores\"`") + requireLine(t, source, "Scores List[*int64] `json:\"scores\"`") } // A field description reaches the author on the generated field. @@ -248,3 +250,223 @@ func TestHandlerScaffold(t *testing.T) { t.Fatalf("author-owned scaffold is marked generated:\n%s", text) } } + +// A contract field name that is legal to core but generates no exported Go +// identifier is named in the error, rather than reaching gofmt as a parse +// error pointing at a file the author never sees. +func TestFieldNameWithoutExportedIdentifierIsRejected(t *testing.T) { + for _, name := range []string{"_1", "_1x"} { + t.Run(name, func(t *testing.T) { + contract := contractOf( + []*resources.RunnableField{field(name, resources.RunnableFieldString)}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + if err := contract.Validate(); err != nil { + t.Fatalf("core rejects %q, so this is no longer the case under test: %v", name, err) + } + _, err := generate.Bindings("wordcount", contract) + if err == nil { + t.Fatal("field name with no exported Go identifier generated without error") + } + if !strings.Contains(err.Error(), name) { + t.Fatalf("error does not name the offending contract field: %v", err) + } + if strings.Contains(err.Error(), "expected") { + t.Fatalf("error is a gofmt parse error rather than a diagnosis: %v", err) + } + }) + } +} + +// Two contract paths that generate one Go type name are reported as the two +// paths an author can act on. The generated name is not a name they wrote. +func TestCollidingTypeNamesNameBothContractPaths(t *testing.T) { + contract := contractOf( + []*resources.RunnableField{ + {Name: "a_b", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{field("x", resources.RunnableFieldString)}}, + {Name: "a", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{ + {Name: "b", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{field("y", resources.RunnableFieldString)}}, + }}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + if err := contract.Validate(); err != nil { + t.Fatalf("core rejects the contract, so this is no longer the case under test: %v", err) + } + _, err := generate.Bindings("wordcount", contract) + if err == nil { + t.Fatal("colliding Go type names generated without error") + } + for _, path := range []string{"input.a_b", "input.a.b"} { + if !strings.Contains(err.Error(), path) { + t.Fatalf("error does not name contract path %s: %v", path, err) + } + } +} + +// A runnable name is a path component, so it may carry characters no Go +// package name may. Rendering one unvalidated puts it in the file as source. +func TestPackageNameMustBeAGoIdentifier(t *testing.T) { + contract := contractOf( + []*resources.RunnableField{field("text", resources.RunnableFieldString)}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + for _, pkg := range []string{"word-count", "", "go", "_", "1count", "wordcount\n\nfunc init() {}"} { + t.Run(pkg, func(t *testing.T) { + if _, err := generate.Bindings(pkg, contract); err == nil { + t.Fatalf("package name %q rendered without error", pkg) + } else if !strings.Contains(err.Error(), "package name") { + t.Fatalf("error does not diagnose the package name: %v", err) + } + if _, err := generate.Handler(pkg, "word-count"); err == nil { + t.Fatalf("Handler rendered package name %q without error", pkg) + } + }) + } +} + +// The runnable name is rendered into a comment, so a line terminator in it +// would end the comment and leave the rest of the name in the file as source. +func TestRunnableNameMustStayInsideItsComment(t *testing.T) { + for _, name := range []string{"", "word\ncount", "word\rcount", "word\x00count"} { + if _, err := generate.Handler("wordcount", name); err == nil { + t.Fatalf("runnable name %q rendered without error", name) + } + } +} + +// Generated bindings only encode an absent key correctly under a toolchain +// that honours omitzero, so they say so in the file rather than leaving it to +// whatever compiles the author's workspace. +func TestGeneratedFileCarriesTheToolchainConstraint(t *testing.T) { + source := generated(t, + []*resources.RunnableField{{Name: "count", Type: resources.RunnableFieldInteger, Optional: true}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + want := "//go:build " + generate.GoRequirement + if !strings.HasPrefix(source, want+"\n") { + t.Fatalf("generated file does not open with %q:\n%s", want, source) + } +} + +// The Optional states are reachable only through the constructors: no field of +// it is exported, so no literal can spell a state the contract does not have. +func TestOptionalStatesAreOnlyReachableThroughConstructors(t *testing.T) { + source := generated(t, + []*resources.RunnableField{{Name: "flag", Type: resources.RunnableFieldBoolean, Optional: true, Nullable: true}}, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + for _, exported := range []string{"Present bool", "Value *T"} { + if strings.Contains(source, exported) { + t.Fatalf("Optional exports %q, so a literal can set a value without marking it present:\n%s", exported, source) + } + } + for _, constructor := range []string{"func Absent[T any]() Optional[T]", "func Null[T any]() Optional[T]", "func Set[T any](value T) Optional[T]"} { + if !strings.Contains(source, constructor) { + t.Fatalf("Optional has no %q:\n%s", constructor, source) + } + } +} + +// An array is a List, and an optional one is carried by Present rather than by +// a pointer: both refuse the null this field does not declare, where a pointer +// would have decoded it as the absent key it is not. +func TestOptionalArrayIsCarriedAndNeverAPointer(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + {Name: "tags", Type: resources.RunnableFieldArray, Optional: true, Items: field("", resources.RunnableFieldString)}, + {Name: "marks", Type: resources.RunnableFieldArray, Optional: true, Nullable: true, Items: field("", resources.RunnableFieldString)}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + requireLine(t, source, "Tags Present[List[string]] `json:\"tags,omitzero\"`") + requireLine(t, source, "Marks Optional[List[string]] `json:\"marks,omitzero\"`") +} + +// The rule that turns a contract name into a generated type name is shared +// with the other runnable agents: an author reading the Go and the Python +// bindings of one contract must see the same names. It is duplicated in +// runnable-python pkg/generate/types.go (camel and nestedName) until it moves +// into core, so it is pinned here against the names that implementation emits. +func TestNamingIsTheSharedCrossLanguageRule(t *testing.T) { + source := generated(t, + []*resources.RunnableField{ + {Name: "stop_words", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{ + {Name: "by_language", Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{field("id", resources.RunnableFieldString)}}, + }}, + {Name: "records", Type: resources.RunnableFieldArray, Items: &resources.RunnableField{ + Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{ + {Name: "sub_records", Type: resources.RunnableFieldArray, Items: &resources.RunnableField{ + Type: resources.RunnableFieldObject, Fields: []*resources.RunnableField{field("id", resources.RunnableFieldString)}, + }}, + }, + }}, + }, + []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, + ) + for _, name := range []string{ + "type InputStopWords struct {", + "type InputStopWordsByLanguage struct {", + "type InputRecordsItem struct {", + "type InputRecordsItemSubRecordsItem struct {", + } { + requireLine(t, source, name) + } +} + +// Generated code is read as a diff by the author who owns the workspace it +// lands in, so the whole file is pinned: declaration order, the header, the +// blank lines and the single emission of each carrier. +func TestGeneratedFileMatchesItsGolden(t *testing.T) { + source, err := generate.Bindings("wordcount", coveringContract()) + if err != nil { + t.Fatalf("Bindings: %v", err) + } + golden := filepath.Join("testdata", "covering_bindings.golden") + if os.Getenv("UPDATE_GOLDEN") != "" { + if err := os.WriteFile(golden, source, 0o600); err != nil { + t.Fatalf("writing %s: %v", golden, err) + } + } + want, err := os.ReadFile(golden) + if err != nil { + t.Fatalf("reading %s: %v", golden, err) + } + if string(source) != string(want) { + t.Fatalf("generated bindings differ from %s; re-run with UPDATE_GOLDEN=1 to accept\n--- got ---\n%s", golden, source) + } +} + +// The scaffold is the author's from its first write, so a second generation +// leaves an implementation exactly as it found it. +func TestWriteHandlerNeverOverwritesAnImplementation(t *testing.T) { + path := filepath.Join(t.TempDir(), "handler.go") + + written, err := generate.WriteHandler(path, "wordcount", "word-count") + if err != nil { + t.Fatalf("WriteHandler: %v", err) + } + if !written { + t.Fatal("the first WriteHandler wrote nothing") + } + + implementation := []byte("package wordcount\n\n// the author's work\n") + if err := os.WriteFile(path, implementation, 0o600); err != nil { + t.Fatalf("writing the implementation: %v", err) + } + + written, err = generate.WriteHandler(path, "wordcount", "word-count") + if err != nil { + t.Fatalf("second WriteHandler: %v", err) + } + if written { + t.Fatal("WriteHandler reported writing over an existing handler") + } + got, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading back: %v", err) + } + if string(got) != string(implementation) { + t.Fatalf("the author's handler was rewritten:\n%s", got) + } +} diff --git a/pkg/generate/compile_test.go b/pkg/generate/compile_test.go index e093a15..71f2596 100644 --- a/pkg/generate/compile_test.go +++ b/pkg/generate/compile_test.go @@ -4,6 +4,7 @@ import ( "os" "os/exec" "path/filepath" + "strings" "testing" "github.com/codefly-dev/core/resources" @@ -11,7 +12,7 @@ import ( ) // coveringContract exercises every value type of the bounded profile and every -// combination of optional and nullable. +// combination of optional and nullable, on a scalar and on an array both. func coveringContract() *resources.RunnableContract { return contractOf( []*resources.RunnableField{ @@ -23,11 +24,34 @@ func coveringContract() *resources.RunnableContract { field("limit", resources.RunnableFieldInteger), }}, {Name: "scores", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldInteger)}, + {Name: "tags", Type: resources.RunnableFieldArray, Optional: true, Items: field("", resources.RunnableFieldString)}, + {Name: "marks", Type: resources.RunnableFieldArray, Nullable: true, Items: field("", resources.RunnableFieldInteger)}, + }, + []*resources.RunnableField{ + field("total", resources.RunnableFieldInteger), + {Name: "results", Type: resources.RunnableFieldArray, Items: field("", resources.RunnableFieldString)}, }, - []*resources.RunnableField{field("total", resources.RunnableFieldInteger)}, ) } +// goDirective is the language version the generated module is compiled at. It +// is read from this repository's go.mod rather than repeated here, so the +// compile test cannot go on exercising a version the repository has left. +func goDirective(t *testing.T) string { + t.Helper() + data, err := os.ReadFile(filepath.Join("..", "..", "go.mod")) + if err != nil { + t.Fatalf("reading go.mod: %v", err) + } + for _, line := range strings.Split(string(data), "\n") { + if rest, ok := strings.CutPrefix(strings.TrimSpace(line), "go "); ok { + return strings.TrimSpace(rest) + } + } + t.Fatal("go.mod declares no go directive") + return "" +} + // The generated bindings and scaffold are only correct if the Go toolchain and // encoding/json agree with them, so they are compiled and exercised as the // author workspace would compile them. @@ -44,7 +68,7 @@ func TestGeneratedPackageCompilesAndKeepsContractSemantics(t *testing.T) { } for name, content := range map[string][]byte{ - "go.mod": []byte("module wordcount\n\ngo 1.27.0\n"), + "go.mod": []byte("module wordcount\n\ngo " + goDirective(t) + "\n"), "bindings.go": bindings, "handler.go": handler, "semantics_test.go": []byte(semanticsTest), @@ -79,9 +103,18 @@ func decode(t *testing.T, payload string) Input { return in } +func encoded(t *testing.T, value any) string { + t.Helper() + data, err := json.Marshal(value) + if err != nil { + t.Fatalf("encoding: %v", err) + } + return string(data) +} + func TestAbsentNullAndSetAreThreeStates(t *testing.T) { - absent := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[]}` + "`" + `) - if absent.Flag.Present { + absent := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[],"marks":null}` + "`" + `) + if absent.Flag.Present() { t.Error("an absent optional-and-nullable key decoded as present") } if _, ok := absent.Count.Get(); ok { @@ -94,13 +127,14 @@ func TestAbsentNullAndSetAreThreeStates(t *testing.T) { t.Error("a null nullable key decoded as a value") } - null := decode(t, ` + "`" + `{"text":"a","note":null,"flag":null,"scores":[]}` + "`" + `) - if !null.Flag.Present || null.Flag.Value != nil { + null := decode(t, ` + "`" + `{"text":"a","note":null,"flag":null,"scores":[],"marks":null}` + "`" + `) + if _, ok := null.Flag.Get(); !null.Flag.Present() || ok { t.Errorf("a null optional-and-nullable key decoded as %+v, want present with no value", null.Flag) } - set := decode(t, ` + "`" + `{"text":"a","note":"n","flag":true,"count":7,"options":{"limit":3},"scores":[1,2]}` + "`" + `) - if !set.Flag.Present || set.Flag.Value == nil || !*set.Flag.Value { + set := decode(t, ` + "`" + `{"text":"a","note":"n","flag":true,"count":7,"options":{"limit":3},"scores":[1,2],"marks":[3]}` + "`" + `) + flag, ok := set.Flag.Get() + if !set.Flag.Present() || !ok || !flag { t.Errorf("a set optional-and-nullable key decoded as %+v", set.Flag) } if count, ok := set.Count.Get(); !ok || count != 7 { @@ -112,34 +146,30 @@ func TestAbsentNullAndSetAreThreeStates(t *testing.T) { if options, ok := set.Options.Get(); !ok || options.Limit != 3 { t.Errorf("options decoded as %+v", set.Options) } + if set.Marks == nil || len(*set.Marks) != 1 { + t.Errorf("marks decoded as %v", set.Marks) + } } func TestEncodingOmitsAbsentKeysAndWritesNull(t *testing.T) { - encoded := func(t *testing.T, in Input) string { - t.Helper() - data, err := json.Marshal(in) - if err != nil { - t.Fatalf("encoding: %v", err) - } - return string(data) - } - - absent := encoded(t, Input{Text: "a", Scores: []int64{}}) - for _, key := range []string{` + "`" + `"flag"` + "`" + `, ` + "`" + `"count"` + "`" + `, ` + "`" + `"options"` + "`" + `} { + absent := encoded(t, Input{Text: "a", Scores: List[int64]{}}) + for _, key := range []string{` + "`" + `"flag"` + "`" + `, ` + "`" + `"count"` + "`" + `, ` + "`" + `"options"` + "`" + `, ` + "`" + `"tags"` + "`" + `} { if strings.Contains(absent, key) { t.Errorf("absent key %s was encoded: %s", key, absent) } } - if !strings.Contains(absent, ` + "`" + `"note":null` + "`" + `) { - t.Errorf("a nullable key was not encoded as null: %s", absent) + for _, key := range []string{` + "`" + `"note":null` + "`" + `, ` + "`" + `"marks":null` + "`" + `} { + if !strings.Contains(absent, key) { + t.Errorf("a nullable key was not encoded as %s: %s", key, absent) + } } - null := encoded(t, Input{Text: "a", Scores: []int64{}, Flag: Optional[bool]{Present: true}}) + null := encoded(t, Input{Text: "a", Scores: List[int64]{}, Flag: Null[bool]()}) if !strings.Contains(null, ` + "`" + `"flag":null` + "`" + `) { t.Errorf("a present-and-null key was not encoded as null: %s", null) } - set := encoded(t, Input{Text: "a", Scores: []int64{}, Count: Value(int64(7))}) + set := encoded(t, Input{Text: "a", Scores: List[int64]{}, Count: Value(int64(7))}) if !strings.Contains(set, ` + "`" + `"count":7` + "`" + `) { t.Errorf("a set optional key was not encoded: %s", set) } @@ -164,8 +194,68 @@ func TestNullIsRefusedForAnOptionalFieldThatIsNotNullable(t *testing.T) { } } +// An array the contract does not declare nullable has no null state, so an +// unset one is the empty array it is. The scaffold returns a zero Output, so +// this is the very first payload a generated runnable produces. +func TestAnUnsetArrayEncodesAsAnEmptyArrayAndNeverAsNull(t *testing.T) { + zero := encoded(t, Output{}) + if !strings.Contains(zero, ` + "`" + `"results":[]` + "`" + `) { + t.Errorf("a zero Output encoded its required array as %s", zero) + } + if strings.Contains(zero, "null") { + t.Errorf("a zero Output put a null on the wire: %s", zero) + } + + roundTripped := encoded(t, decode(t, ` + "`" + `{"text":"a","note":null,"scores":[1],"marks":null}` + "`" + `)) + if strings.Contains(roundTripped, ` + "`" + `"scores":null` + "`" + `) { + t.Errorf("decoding and re-encoding turned an array into null: %s", roundTripped) + } + for _, key := range []string{` + "`" + `"count"` + "`" + `, ` + "`" + `"flag"` + "`" + `, ` + "`" + `"tags"` + "`" + `} { + if strings.Contains(roundTripped, key) { + t.Errorf("decoding and re-encoding turned absent key %s into a written one: %s", key, roundTripped) + } + } +} + +// A null for an array the contract does not declare nullable is refused rather +// than decoded as an empty or absent array. +func TestNullIsRefusedForAnArrayThatIsNotNullable(t *testing.T) { + for _, payload := range []string{ + ` + "`" + `{"text":"a","scores":null}` + "`" + `, + ` + "`" + `{"text":"a","scores":[],"tags":null}` + "`" + `, + } { + var in Input + if err := json.Unmarshal([]byte(payload), &in); err == nil { + t.Errorf("%s decoded without error", payload) + } + } + var in Input + if err := json.Unmarshal([]byte(` + "`" + `{"text":"a","scores":[],"marks":null}` + "`" + `), &in); err != nil { + t.Errorf("a null for a nullable array was refused: %v", err) + } else if in.Marks != nil { + t.Errorf("a null nullable array decoded as %v", in.Marks) + } +} + +// A value handed to an optional-and-nullable field is on the wire. It cannot +// be set without also being present, and an absent one is never written as a +// null that would claim the author chose null. +func TestASetValueIsNeverDroppedAndAnAbsentOneIsNeverANull(t *testing.T) { + written := encoded(t, Input{Text: "a", Scores: List[int64]{}, Flag: Set(true)}) + if !strings.Contains(written, ` + "`" + `"flag":true` + "`" + `) { + t.Errorf("a set value was dropped from the payload: %s", written) + } + + if _, err := json.Marshal(Absent[bool]()); err == nil { + t.Error("an absent optional encoded as a value instead of being refused") + } + if value, ok := Absent[bool]().Get(); ok || value { + t.Errorf("an absent optional reported a value: %v %v", value, ok) + } +} + func TestIntegerIsSigned64Bit(t *testing.T) { - in := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[9223372036854775807,-9223372036854775808]}` + "`" + `) + in := decode(t, ` + "`" + `{"text":"a","note":null,"scores":[9223372036854775807,-9223372036854775808],"marks":null}` + "`" + `) if in.Scores[0] != 9223372036854775807 || in.Scores[1] != -9223372036854775808 { t.Errorf("64-bit bounds decoded as %v", in.Scores) } @@ -183,6 +273,7 @@ func TestNoImplicitCoercion(t *testing.T) { ` + "`" + `{"text":"a","flag":"true"}` + "`" + `, ` + "`" + `{"text":"a","scores":[true]}` + "`" + `, ` + "`" + `{"text":"a","options":[]}` + "`" + `, + ` + "`" + `{"text":"a","scores":{}}` + "`" + `, } { var in Input if err := json.Unmarshal([]byte(payload), &in); err == nil { diff --git a/pkg/generate/handler.go b/pkg/generate/handler.go index c314542..c213752 100644 --- a/pkg/generate/handler.go +++ b/pkg/generate/handler.go @@ -3,13 +3,24 @@ package generate import ( "fmt" "go/format" + "os" "strings" + "unicode" ) -// Handler renders the author entrypoint of a runnable. The scaffold is written -// once and owned by the author from then on, so it carries no generated marker -// and is never rewritten over their implementation. +// Handler renders the author entrypoint of a runnable. The scaffold carries no +// generated marker because the author owns it from its first write onward. +// +// Handler renders; it does not write. WriteHandler is what keeps the scaffold +// from being written over an implementation, and is the only way this package +// puts a handler on disk. func Handler(pkg string, name string) ([]byte, error) { + if err := validatePackageName(pkg); err != nil { + return nil, err + } + if err := validateRunnableName(name); err != nil { + return nil, err + } var b strings.Builder fmt.Fprintf(&b, "package %s\n\n", pkg) b.WriteString("import (\n\t\"context\"\n\t\"errors\"\n)\n\n") @@ -19,3 +30,46 @@ func Handler(pkg string, name string) ([]byte, error) { b.WriteString("}\n") return format.Source([]byte(b.String())) } + +// WriteHandler writes the scaffold at path unless a file is already there, and +// reports whether it wrote one. +// +// The author owns the handler from its first write, so regenerating a runnable +// must never rewrite it: an implementation overwritten by a scaffold that +// returns "handler is not implemented" is gone. The file is created +// exclusively rather than checked and then written, so two generations racing +// on one path cannot both decide the file is absent. +func WriteHandler(path string, pkg string, name string) (bool, error) { + content, err := Handler(pkg, name) + if err != nil { + return false, err + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0o644) + if err != nil { + if os.IsExist(err) { + return false, nil + } + return false, err + } + if _, err := file.Write(content); err != nil { + file.Close() + return false, err + } + return true, file.Close() +} + +// validateRunnableName rejects a name that would not stay inside the comment it +// is rendered into. A runnable name is a path component, not a Go identifier, +// so it is quoted into prose rather than into code — but a line terminator in +// it would end the comment and put the rest of the name in the file as source. +func validateRunnableName(name string) error { + if name == "" { + return fmt.Errorf("runnable name is empty") + } + for _, r := range name { + if r == '\n' || r == '\r' || unicode.IsControl(r) { + return fmt.Errorf("runnable name %q carries a control character", name) + } + } + return nil +} diff --git a/pkg/generate/testdata/covering_bindings.golden b/pkg/generate/testdata/covering_bindings.golden new file mode 100644 index 0000000..66b5933 --- /dev/null +++ b/pkg/generate/testdata/covering_bindings.golden @@ -0,0 +1,188 @@ +//go:build go1.24 + +// Code generated by runnable-go. DO NOT EDIT. + +package wordcount + +import ( + "encoding/json" + "errors" +) + +// Input is the typed invocation input declared by the contract. +type Input struct { + Text string `json:"text"` + Count Present[int64] `json:"count,omitzero"` + Note *string `json:"note"` + Flag Optional[bool] `json:"flag,omitzero"` + Options Present[InputOptions] `json:"options,omitzero"` + Scores List[int64] `json:"scores"` + Tags Present[List[string]] `json:"tags,omitzero"` + Marks *List[int64] `json:"marks"` +} + +// Output is the typed invocation output declared by the contract. +type Output struct { + Total int64 `json:"total"` + Results List[string] `json:"results"` +} + +// InputOptions is a nested object of the contract. +type InputOptions struct { + Limit int64 `json:"limit"` +} + +// Optional carries a field the contract declares both optional and nullable. +// Absent, null and set are three separate states there, so none may stand for +// another. +// +// The states are reachable only through Absent, Null and Set, and the zero +// Optional is absent, so no combination of fields can spell a state the +// contract does not have. +type Optional[T any] struct { + present bool + value *T +} + +// Absent is the state of a key the payload does not carry. It is the zero +// Optional. +func Absent[T any]() Optional[T] { + return Optional[T]{} +} + +// Null is the state of a key the payload carries as null. +func Null[T any]() Optional[T] { + return Optional[T]{present: true} +} + +// Set is the state of a key the payload carries with a value. +func Set[T any](value T) Optional[T] { + return Optional[T]{present: true, value: &value} +} + +// Present reports whether the payload carried the key at all. +func (o Optional[T]) Present() bool { + return o.present +} + +// Get returns the carried value, and false when the key was absent or null. +func (o Optional[T]) Get() (T, bool) { + if o.value == nil { + var zero T + return zero, false + } + return *o.value, true +} + +// IsZero reports an absent key, which is what omits it from an encoded payload. +func (o Optional[T]) IsZero() bool { + return !o.present +} + +// MarshalJSON encodes the null and the set state. The absent state has no +// encoding as a value: every generated field carries omitzero, so an absent +// Optional is omitted with its key and never reaches here. Reaching here +// absent means the value is being encoded somewhere a key cannot be omitted, +// where writing null would turn absent into null without saying so. +func (o Optional[T]) MarshalJSON() ([]byte, error) { + if !o.present { + return nil, errors.New("an absent optional value has no encoding: it is omitted with its key, never written as null") + } + if o.value == nil { + return []byte("null"), nil + } + return json.Marshal(o.value) +} + +func (o *Optional[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + o.present, o.value = true, nil + return nil + } + var value T + if err := json.Unmarshal(data, &value); err != nil { + return err + } + o.present, o.value = true, &value + return nil +} + +// Present carries a field the contract declares optional and not nullable: the +// key may be absent, and no value it carries may be null. +// +// It is a value and not a pointer because encoding/json sets a pointer field to +// nil for an explicit null without ever consulting an unmarshaler, which admits +// a null the declaration forbids and leaves it indistinguishable from the key +// being absent. +// +// The value is reachable only through Value, and the zero Present is absent, so +// no literal can spell a key that is absent and carries a value at once. +type Present[T any] struct { + set bool + value T +} + +// Value is the state of a key the payload carries. +func Value[T any](value T) Present[T] { + return Present[T]{set: true, value: value} +} + +// Get returns the carried value, and false when the key was absent. +func (p Present[T]) Get() (T, bool) { + return p.value, p.set +} + +// IsZero reports an absent key, which is what omits it from an encoded payload. +func (p Present[T]) IsZero() bool { + return !p.set +} + +// MarshalJSON encodes the carried value. The absent state has no encoding as a +// value: every generated field carries omitzero, so an absent Present is +// omitted with its key and never reaches here. Reaching here absent means the +// value is being encoded where a key cannot be omitted, where null would stand +// in for absent and would itself be a value the declaration forbids. +func (p Present[T]) MarshalJSON() ([]byte, error) { + if !p.set { + return nil, errors.New("an absent optional value has no encoding: it is omitted with its key, never written as null") + } + return json.Marshal(p.value) +} + +func (p *Present[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return errors.New("value is null but the contract does not declare the field nullable") + } + if err := json.Unmarshal(data, &p.value); err != nil { + return err + } + p.set = true + return nil +} + +// List carries an array of the contract. +// +// A nil Go slice encodes as null, and an array is null only where the contract +// declares the field nullable — a state spelled as the nil *List, never as a +// nil List. So an unset List encodes as the empty array it is, and a null +// payload is refused rather than decoded as an empty array. +type List[T any] []T + +func (l List[T]) MarshalJSON() ([]byte, error) { + if l == nil { + return []byte("[]"), nil + } + return json.Marshal([]T(l)) +} + +func (l *List[T]) UnmarshalJSON(data []byte) error { + if string(data) == "null" { + return errors.New("array is null but the contract does not declare it nullable") + } + var values []T + if err := json.Unmarshal(data, &values); err != nil { + return err + } + *l = values + return nil +}