Validate cross-generator consistency for nullable and empty_behavior annotations across all 4 generators.

Purpose: Ensure the same proto definitions produce semantically identical JSON across go-http, go-client, ts-client, and OpenAPI. The server must serialize what clients expect, and OpenAPI must document what both produce.

Output: Cross-generator consistency tests that verify JSON output matches between generators and that OpenAPI schemas accurately reflect the serialization behavior.

<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 @.planning/phases/05-json-nullable-empty/05-02-SUMMARY.md @.planning/phases/05-json-nullable-empty/05-03-SUMMARY.md

Existing consistency test patterns from Phase 4

@internal/httpgen/encoding_consistency_test.go @internal/httpgen/enum_encoding_consistency_test.go

Task 1: Create nullable cross-generator consistency tests internal/httpgen/nullable_consistency_test.go Create internal/httpgen/nullable_consistency_test.go following the pattern from Phase 4 consistency tests:
package httpgen_test
 
import (
    "bytes"
    "regexp"
    "strings"
    "testing"
 
    "github.com/stretchr/testify/require"
)
 
// TestNullableConsistencyGoHTTPvsGoClient verifies that go-http and go-client
// generate identical MarshalJSON/UnmarshalJSON code for nullable fields.
func TestNullableConsistencyGoHTTPvsGoClient(t *testing.T) {
    // Generate nullable output for both generators
    httpGenOutput := generateHTTPNullableOutput(t)
    clientGenOutput := generateClientNullableOutput(t)
 
    // Normalize package names and comments for comparison
    normalizedHTTP := normalizeGeneratorOutput(httpGenOutput, "httpgen")
    normalizedClient := normalizeGeneratorOutput(clientGenOutput, "clientgen")
 
    // Compare the core MarshalJSON logic
    require.Equal(t, normalizedHTTP, normalizedClient,
        "go-http and go-client nullable implementations must be identical")
}
 
// TestNullableConsistencyTypeScript verifies TypeScript types match Go nullable behavior.
func TestNullableConsistencyTypeScript(t *testing.T) {
    // For nullable=true fields:
    // - Go emits null when unset
    // - TypeScript must use T | null (not T?)
 
    tsOutput := generateTSNullableOutput(t)
 
    // Verify nullable fields use union type, not optional
    // Example: middleName: string | null (not middleName?: string)
    require.Contains(t, tsOutput, "| null",
        "TypeScript must use T | null for nullable fields")
 
    // Verify pattern: fieldName: type | null
    nullablePattern := regexp.MustCompile(`\w+:\s+\w+\s+\|\s+null`)
    require.True(t, nullablePattern.MatchString(tsOutput),
        "TypeScript nullable fields must use 'fieldName: T | null' pattern")
}
 
// TestNullableConsistencyOpenAPI verifies OpenAPI schemas match Go nullable behavior.
func TestNullableConsistencyOpenAPI(t *testing.T) {
    // For nullable=true fields:
    // - Go emits null when unset
    // - OpenAPI 3.1 must use type: ["T", "null"] array syntax
 
    openapiOutput := generateOpenAPINullableOutput(t)
 
    // Verify OpenAPI uses type array for nullable fields
    // Example: type: ["string", "null"]
    require.Contains(t, openapiOutput, `"null"`,
        "OpenAPI must include null in type array for nullable fields")
 
    // Verify it's in array format, not the deprecated nullable: true
    require.NotContains(t, openapiOutput, "nullable: true",
        "OpenAPI must NOT use deprecated nullable: true syntax")
}
 
// TestNullableSerializationRoundTrip verifies nullable fields roundtrip correctly.
func TestNullableSerializationRoundTrip(t *testing.T) {
    testCases := []struct {
        name     string
        input    string // JSON input
        expected string // Expected JSON output
    }{
        {
            name:     "unset nullable field emits null",
            input:    `{}`,
            expected: `"middleName":null`, // nullable=true emits null when unset
        },
        {
            name:     "set nullable field emits value",
            input:    `{"middleName":"John"}`,
            expected: `"middleName":"John"`,
        },
        {
            name:     "explicit null on nullable field roundtrips",
            input:    `{"middleName":null}`,
            expected: `"middleName":null`,
        },
    }
 
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            // Test with generated code
            output := marshalUnmarshalNullable(t, tc.input)
            require.Contains(t, output, tc.expected,
                "Nullable field serialization must match expected output")
        })
    }
}
 
// Helper functions to generate output from each generator
// These use the test infrastructure to run generators on nullable.proto
 
func generateHTTPNullableOutput(t *testing.T) string {
    t.Helper()
    // Use existing golden file test infrastructure
    // Read the generated _nullable.pb.go output
    return readGeneratedFile(t, "internal/httpgen/testdata", "nullable", "_nullable.pb.go")
}
 
func generateClientNullableOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/clientgen/testdata", "nullable", "_nullable.pb.go")
}
 
func generateTSNullableOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/tsclientgen/testdata", "nullable", ".ts")
}
 
func generateOpenAPINullableOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/openapiv3/testdata", "nullable", ".openapi.yaml")
}
 
func normalizeGeneratorOutput(output, generatorName string) string {
    // Remove generator-specific package names
    output = strings.ReplaceAll(output, "package httpgen", "package NORMALIZED")
    output = strings.ReplaceAll(output, "package clientgen", "package NORMALIZED")
 
    // Remove file path comments that differ
    lines := strings.Split(output, "\n")
    var normalized []string
    for _, line := range lines {
        if strings.HasPrefix(line, "// Code generated") {
            continue
        }
        normalized = append(normalized, line)
    }
 
    return strings.Join(normalized, "\n")
}
 
func readGeneratedFile(t *testing.T, testdataDir, protoName, suffix string) string {
    t.Helper()
    // Implementation: read the generated golden file
    // This will be filled in based on the test infrastructure
    return ""
}
 
func marshalUnmarshalNullable(t *testing.T, input string) string {
    t.Helper()
    // Implementation: use generated code to marshal/unmarshal
    return ""
}

The test file should:

  1. Compare generated MarshalJSON between go-http and go-client (byte-level after normalization)
  2. Verify TypeScript uses T | null syntax
  3. Verify OpenAPI uses type array ["T", "null"] syntax
  4. Test serialization roundtrip behavior

Adapt the helper functions to use the actual test infrastructure and golden file patterns from the codebase.

go test -v -run TestNullableConsistency ./internal/httpgen/...
make lint-fix
- TestNullableConsistencyGoHTTPvsGoClient passes - TestNullableConsistencyTypeScript passes - TestNullableConsistencyOpenAPI passes - TestNullableSerializationRoundTrip passes - go-http and go-client produce identical nullable JSON - TypeScript uses T | null syntax - OpenAPI uses type array syntax
Task 2: Create empty_behavior cross-generator consistency tests internal/httpgen/empty_behavior_consistency_test.go Create internal/httpgen/empty_behavior_consistency_test.go:
package httpgen_test
 
import (
    "regexp"
    "strings"
    "testing"
 
    "github.com/stretchr/testify/require"
)
 
// TestEmptyBehaviorConsistencyGoHTTPvsGoClient verifies go-http and go-client
// generate identical MarshalJSON code for empty_behavior fields.
func TestEmptyBehaviorConsistencyGoHTTPvsGoClient(t *testing.T) {
    httpGenOutput := generateHTTPEmptyBehaviorOutput(t)
    clientGenOutput := generateClientEmptyBehaviorOutput(t)
 
    normalizedHTTP := normalizeGeneratorOutput(httpGenOutput, "httpgen")
    normalizedClient := normalizeGeneratorOutput(clientGenOutput, "clientgen")
 
    require.Equal(t, normalizedHTTP, normalizedClient,
        "go-http and go-client empty_behavior implementations must be identical")
}
 
// TestEmptyBehaviorConsistencyOpenAPI verifies OpenAPI schemas match Go behavior.
func TestEmptyBehaviorConsistencyOpenAPI(t *testing.T) {
    openapiOutput := generateOpenAPIEmptyBehaviorOutput(t)
 
    // For empty_behavior=NULL fields:
    // - Go emits null when message is empty
    // - OpenAPI must use oneOf with null type
 
    // Verify oneOf pattern for NULL fields
    require.Contains(t, openapiOutput, "oneOf",
        "OpenAPI must use oneOf for empty_behavior=NULL fields")
 
    // Verify null type in oneOf
    nullInOneOfPattern := regexp.MustCompile(`oneOf.*\n.*\$ref.*\n.*type.*null`)
    require.True(t, nullInOneOfPattern.MatchString(openapiOutput) ||
        strings.Contains(openapiOutput, `"null"`),
        "OpenAPI oneOf must include null type for empty_behavior=NULL")
}
 
// TestEmptyBehaviorSerializationRoundTrip verifies empty_behavior fields roundtrip correctly.
func TestEmptyBehaviorSerializationRoundTrip(t *testing.T) {
    testCases := []struct {
        name     string
        behavior string
        input    string // JSON input
        expected string // Expected pattern in output
        absent   string // Pattern that should NOT be in output
    }{
        {
            name:     "NULL empty message becomes null",
            behavior: "NULL",
            input:    `{"id":"1","metadataNull":{}}`,
            expected: `"metadataNull":null`,
        },
        {
            name:     "OMIT empty message is removed",
            behavior: "OMIT",
            input:    `{"id":"1","metadataOmit":{}}`,
            absent:   `"metadataOmit"`,
        },
        {
            name:     "PRESERVE empty message stays as {}",
            behavior: "PRESERVE",
            input:    `{"id":"1","metadataPreserve":{}}`,
            expected: `"metadataPreserve":{}`,
        },
        {
            name:     "NULL non-empty message stays as object",
            behavior: "NULL",
            input:    `{"id":"1","metadataNull":{"key":"k","value":"v"}}`,
            expected: `"metadataNull":{"key":"k"`,
        },
        {
            name:     "NULL accepts null on unmarshal",
            behavior: "NULL",
            input:    `{"id":"1","metadataNull":null}`,
            expected: `"metadataNull":null`,
        },
    }
 
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            output := marshalUnmarshalEmptyBehavior(t, tc.input)
 
            if tc.expected != "" {
                require.Contains(t, output, tc.expected,
                    "Empty behavior %s must produce expected output", tc.behavior)
            }
            if tc.absent != "" {
                require.NotContains(t, output, tc.absent,
                    "Empty behavior %s must NOT contain absent pattern", tc.behavior)
            }
        })
    }
}
 
// TestEmptyMessageDetection verifies proto.Size() == 0 correctly detects empty.
func TestEmptyMessageDetection(t *testing.T) {
    testCases := []struct {
        name    string
        message string // JSON representation
        isEmpty bool
    }{
        {
            name:    "all defaults is empty",
            message: `{}`,
            isEmpty: true,
        },
        {
            name:    "zero string is empty",
            message: `{"key":""}`,
            isEmpty: true,
        },
        {
            name:    "zero int is empty",
            message: `{"count":0}`,
            isEmpty: true,
        },
        {
            name:    "non-zero string is not empty",
            message: `{"key":"value"}`,
            isEmpty: false,
        },
        {
            name:    "non-zero int is not empty",
            message: `{"count":1}`,
            isEmpty: false,
        },
    }
 
    for _, tc := range testCases {
        t.Run(tc.name, func(t *testing.T) {
            isEmpty := isMessageEmpty(t, tc.message)
            require.Equal(t, tc.isEmpty, isEmpty,
                "Empty detection must match expected for %s", tc.message)
        })
    }
}
 
func generateHTTPEmptyBehaviorOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/httpgen/testdata", "empty_behavior", "_empty_behavior.pb.go")
}
 
func generateClientEmptyBehaviorOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/clientgen/testdata", "empty_behavior", "_empty_behavior.pb.go")
}
 
func generateOpenAPIEmptyBehaviorOutput(t *testing.T) string {
    t.Helper()
    return readGeneratedFile(t, "internal/openapiv3/testdata", "empty_behavior", ".openapi.yaml")
}
 
func marshalUnmarshalEmptyBehavior(t *testing.T, input string) string {
    t.Helper()
    // Implementation: use generated code to marshal/unmarshal
    return ""
}
 
func isMessageEmpty(t *testing.T, jsonMessage string) bool {
    t.Helper()
    // Implementation: unmarshal and check proto.Size() == 0
    return false
}

The test file should:

  1. Compare generated MarshalJSON between go-http and go-client
  2. Verify OpenAPI uses oneOf for NULL fields
  3. Test serialization behavior for NULL, OMIT, PRESERVE modes
  4. Test proto.Size() == 0 empty detection matches expectations

Adapt helper functions to use actual test infrastructure.

go test -v -run TestEmptyBehaviorConsistency ./internal/httpgen/...
make lint-fix
- TestEmptyBehaviorConsistencyGoHTTPvsGoClient passes - TestEmptyBehaviorConsistencyOpenAPI passes - TestEmptyBehaviorSerializationRoundTrip passes for NULL, OMIT, PRESERVE - TestEmptyMessageDetection passes - go-http and go-client produce identical empty_behavior JSON - OpenAPI uses oneOf for NULL fields
Task 3: Run full test suite and verify all golden files (verification only - no new files) Run the complete test suite to verify:
  1. All existing tests still pass (zero regression)
  2. All new consistency tests pass
  3. Golden files are up to date
  4. Lint is clean
# Full test suite
go test -v ./...
 
# Specific generator tests
go test -v ./internal/httpgen/... ./internal/clientgen/... ./internal/tsclientgen/... ./internal/openapiv3/...
 
# Golden file tests
go test -v -run TestExhaustiveGoldenFiles ./internal/...
 
# Consistency tests
go test -v -run TestNullableConsistency ./internal/httpgen/...
go test -v -run TestEmptyBehaviorConsistency ./internal/httpgen/...
 
# Lint check
make lint-fix

Verify that:

  • nullable.proto and empty_behavior.proto golden files exist for all generators
  • Generated code matches expected golden output
  • Cross-generator consistency verified

If any test fails, debug and fix the implementation in plans 02/03.

go test -v ./...
make lint-fix

All tests pass, lint clean, no regressions.

  • All 4 generators produce consistent output for nullable fields
  • All 4 generators produce consistent output for empty_behavior fields
  • Cross-generator consistency tests pass
  • All existing tests pass (zero regression)
  • Lint clean
1. Consistency tests pass: `go test -v -run Consistency ./internal/httpgen/...` 2. Full test suite passes: `go test -v ./...` 3. Lint clean: `make lint-fix` 4. Golden files up to date: `go test -run TestExhaustiveGoldenFiles ./internal/...` 5. Cross-generator verification: - go-http JSON == go-client JSON (byte-level after normalization) - TypeScript types match Go serialization (T | null for nullable) - OpenAPI schemas match Go serialization (type arrays for nullable, oneOf for NULL)

<success_criteria>

  • go-http and go-client produce byte-identical JSON for nullable fields
  • go-http and go-client produce byte-identical JSON for empty_behavior fields
  • TypeScript T | null types correctly represent Go's null emission
  • OpenAPI type: ["T", "null"] correctly represents Go's null emission
  • OpenAPI oneOf with null type correctly represents empty_behavior=NULL
  • All serialization roundtrip tests pass
  • proto.Size() == 0 empty detection works correctly
  • Zero regressions in existing tests </success_criteria>
After completion, create `.planning/phases/05-json-nullable-empty/05-04-SUMMARY.md`