Define nullable and empty_behavior proto annotations and create shared annotation parsing functions in internal/annotations.

Purpose: Establish the annotation infrastructure that all 4 generators will use to control nullable primitive serialization and empty message handling. This is the foundation for the rest of Phase 5.

Output: Proto extensions for nullable (bool) and empty_behavior (enum) annotations. Shared Go functions IsNullableField, ValidateNullableAnnotation, GetEmptyBehavior, and ValidateEmptyBehaviorAnnotation in internal/annotations package.

<execution_context> @/Users/sebastienmelki/.claude/get-shit-done/workflows/execute-plan.md @/Users/sebastienmelki/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/05-json-nullable-empty/05-CONTEXT.md @.planning/phases/05-json-nullable-empty/05-RESEARCH.md

Existing annotation patterns to follow

@internal/annotations/int64_encoding.go @internal/annotations/enum_encoding.go @internal/annotations/unwrap.go @proto/sebuf/http/annotations.proto

Task 1: Add nullable and empty_behavior annotations to proto/sebuf/http/annotations.proto proto/sebuf/http/annotations.proto Add the following proto definitions after the existing FieldOptions extensions:
  1. EmptyBehavior enum (add before the FieldOptions extend block):
// EmptyBehavior controls how empty message fields serialize to JSON.
// "Empty" means all fields at proto default (proto.Size() == 0).
enum EmptyBehavior {
  // Follow default behavior: serialize empty messages as {} (same as PRESERVE)
  EMPTY_BEHAVIOR_UNSPECIFIED = 0;
  // Serialize empty messages as {} (explicit same as default)
  EMPTY_BEHAVIOR_PRESERVE = 1;
  // Serialize empty messages as null
  EMPTY_BEHAVIOR_NULL = 2;
  // Omit the field entirely when message is empty
  EMPTY_BEHAVIOR_OMIT = 3;
}
  1. Add to existing FieldOptions extension block (inside the extend google.protobuf.FieldOptions block):
  // Mark a primitive field as nullable (explicit null vs absent).
  // Only valid on proto3 optional fields (HasOptionalKeyword=true).
  // When true: unset field serializes as null, set field serializes normally.
  // When false (default): unset field is omitted from JSON.
  optional bool nullable = 50013;
 
  // Controls how empty message fields serialize to JSON.
  // Only valid on singular message fields (not repeated, not map).
  // "Empty" = all fields at proto default (proto.Size() == 0).
  optional EmptyBehavior empty_behavior = 50014;

Use extension numbers 50013 and 50014 (continuing from existing 50012 for enum_value). Run buf build to verify the proto compiles without errors:

cd proto && buf build

Regenerate Go code:

cd proto && buf generate

Verify the generated Go code compiles:

go build ./http/...
- EmptyBehavior enum exists in annotations.proto with UNSPECIFIED, PRESERVE, NULL, OMIT values - nullable field extension exists (bool, number 50013) - empty_behavior field extension exists (EmptyBehavior, number 50014) - Generated Go code in http/ package compiles - E_Nullable and E_EmptyBehavior extension descriptors accessible
Task 2: Create internal/annotations/nullable.go and empty_behavior.go internal/annotations/nullable.go internal/annotations/empty_behavior.go internal/annotations/annotations_test.go Create nullable.go following the pattern in int64_encoding.go:
package annotations
 
import (
    "google.golang.org/protobuf/compiler/protogen"
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/reflect/protoreflect"
    "google.golang.org/protobuf/types/descriptorpb"
 
    "github.com/SebastienMelki/sebuf/http"
)
 
// NullableValidationError represents an error in nullable annotation validation.
type NullableValidationError struct {
    MessageName string
    FieldName   string
    Reason      string
}
 
func (e *NullableValidationError) Error() string {
    return "invalid nullable annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
 
// IsNullableField returns true if the field has nullable=true annotation.
func IsNullableField(field *protogen.Field) bool {
    options := field.Desc.Options()
    if options == nil {
        return false
    }
 
    fieldOptions, ok := options.(*descriptorpb.FieldOptions)
    if !ok {
        return false
    }
 
    ext := proto.GetExtension(fieldOptions, http.E_Nullable)
    if ext == nil {
        return false
    }
 
    nullable, ok := ext.(bool)
    return ok && nullable
}
 
// ValidateNullableAnnotation checks if nullable annotation is valid for a field.
// Returns error if nullable=true on a non-optional field or on a message field.
func ValidateNullableAnnotation(field *protogen.Field, messageName string) error {
    if !IsNullableField(field) {
        return nil // No annotation, nothing to validate
    }
 
    // Nullable only valid on proto3 optional fields
    if !field.Desc.HasOptionalKeyword() {
        return &NullableValidationError{
            MessageName: messageName,
            FieldName:   string(field.Desc.Name()),
            Reason:      "nullable annotation is only valid on proto3 optional fields",
        }
    }
 
    // Nullable only valid on primitive types (not messages)
    if field.Desc.Kind() == protoreflect.MessageKind {
        return &NullableValidationError{
            MessageName: messageName,
            FieldName:   string(field.Desc.Name()),
            Reason:      "nullable annotation is only valid on primitive fields, not message fields",
        }
    }
 
    return nil
}

Create empty_behavior.go:

package annotations
 
import (
    "google.golang.org/protobuf/compiler/protogen"
    "google.golang.org/protobuf/proto"
    "google.golang.org/protobuf/reflect/protoreflect"
    "google.golang.org/protobuf/types/descriptorpb"
 
    "github.com/SebastienMelki/sebuf/http"
)
 
// EmptyBehaviorValidationError represents an error in empty_behavior annotation validation.
type EmptyBehaviorValidationError struct {
    MessageName string
    FieldName   string
    Reason      string
}
 
func (e *EmptyBehaviorValidationError) Error() string {
    return "invalid empty_behavior annotation on " + e.MessageName + "." + e.FieldName + ": " + e.Reason
}
 
// GetEmptyBehavior returns the empty behavior for a field.
// Returns EMPTY_BEHAVIOR_UNSPECIFIED if not set (callers should treat as PRESERVE).
func GetEmptyBehavior(field *protogen.Field) http.EmptyBehavior {
    options := field.Desc.Options()
    if options == nil {
        return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
    }
 
    fieldOptions, ok := options.(*descriptorpb.FieldOptions)
    if !ok {
        return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
    }
 
    ext := proto.GetExtension(fieldOptions, http.E_EmptyBehavior)
    if ext == nil {
        return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
    }
 
    behavior, ok := ext.(http.EmptyBehavior)
    if !ok {
        return http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
    }
 
    return behavior
}
 
// HasEmptyBehaviorAnnotation returns true if the field has any empty_behavior annotation set
// (including explicit PRESERVE, NULL, or OMIT - not just UNSPECIFIED).
func HasEmptyBehaviorAnnotation(field *protogen.Field) bool {
    return GetEmptyBehavior(field) != http.EmptyBehavior_EMPTY_BEHAVIOR_UNSPECIFIED
}
 
// ValidateEmptyBehaviorAnnotation checks if empty_behavior annotation is valid for a field.
// Returns error if used on primitive, repeated, or map fields.
func ValidateEmptyBehaviorAnnotation(field *protogen.Field, messageName string) error {
    if !HasEmptyBehaviorAnnotation(field) {
        return nil // No annotation, nothing to validate
    }
 
    // Empty behavior only valid on message fields
    if field.Desc.Kind() != protoreflect.MessageKind {
        return &EmptyBehaviorValidationError{
            MessageName: messageName,
            FieldName:   string(field.Desc.Name()),
            Reason:      "empty_behavior annotation is only valid on message fields",
        }
    }
 
    // Not valid on repeated fields
    if field.Desc.IsList() {
        return &EmptyBehaviorValidationError{
            MessageName: messageName,
            FieldName:   string(field.Desc.Name()),
            Reason:      "empty_behavior annotation is not valid on repeated fields",
        }
    }
 
    // Not valid on map fields
    if field.Desc.IsMap() {
        return &EmptyBehaviorValidationError{
            MessageName: messageName,
            FieldName:   string(field.Desc.Name()),
            Reason:      "empty_behavior annotation is not valid on map fields",
        }
    }
 
    return nil
}

Add unit tests to annotations_test.go for:

  • Extension descriptor availability (E_Nullable, E_EmptyBehavior)
  • EmptyBehavior enum values are correctly defined
  • NullableValidationError and EmptyBehaviorValidationError error message format
go build ./internal/annotations/...
go test -v ./internal/annotations/...
make lint-fix

All tests pass, lint clean.

  • IsNullableField function exists and returns true only for nullable=true fields
  • ValidateNullableAnnotation rejects non-optional fields and message fields
  • GetEmptyBehavior function exists and returns correct EmptyBehavior enum
  • HasEmptyBehaviorAnnotation helper exists
  • ValidateEmptyBehaviorAnnotation rejects primitives, repeated, and map fields
  • NullableValidationError and EmptyBehaviorValidationError types with Error() method
  • All functions follow existing annotation package patterns
  • Unit tests cover extension descriptors and error types
  • Lint passes
1. Proto annotations compile: `cd proto && buf build && buf generate` 2. Go package compiles: `go build ./http/... ./internal/annotations/...` 3. Tests pass: `go test -v ./internal/annotations/...` 4. Lint clean: `make lint-fix` reports no issues 5. Verify extension numbers don't conflict with existing (50010=int64_encoding, 50011=enum_encoding, 50012=enum_value)

<success_criteria>

  • Proto files can import and use nullable and empty_behavior annotations
  • IsNullableField(field) returns true only for fields annotated with nullable=true
  • ValidateNullableAnnotation returns error for invalid usage (non-optional, message fields)
  • GetEmptyBehavior(field) returns the configured EmptyBehavior enum value
  • ValidateEmptyBehaviorAnnotation returns error for invalid usage (primitives, repeated, maps)
  • All existing tests continue to pass (zero regression) </success_criteria>
After completion, create `.planning/phases/05-json-nullable-empty/05-01-SUMMARY.md`