Skip to content

Non-deterministic key order for nested objects under additionalPackageJSON in generated package.json #2040

Description

@timhaines

Summary

speakeasy run emits non-deterministic key ordering for nested objects supplied via additionalPackageJSON in the generated package.json. Re-running the generator against an unchanged spec and unchanged gen.yaml flips the key order of nested objects (repository, engines, pnpm.overrides, …) on a subset of runs. This defeats byte-level idempotency / "regen-survival" checks in CI and produces noisy, meaningless diffs on every regeneration.

  • CLI version: speakeasy 1.763.1 (darwin_arm64)
  • Target: typescript
  • Scope: confined to nested objects originating from gen.yaml typescript.additionalPackageJSON. Speakeasy-native top-level keys are stably ordered.

Repro

gen.yaml (excerpt):

typescript:
  additionalPackageJSON:
    engines:
      node: '>=22'
      pnpm: '>=10'
    repository:
      type: git
      url: git+https://github.com/example/sdk-js.git
    pnpm:
      overrides:
        vite: ^8
        postcss: '>=8.5.10'

Run repeatedly:

for i in $(seq 6); do
  speakeasy run --skip-compile --skip-upload-spec --skip-versioning >/dev/null 2>&1
  python3 -c "import json;p=json.load(open('package.json'));print('repository:',list(p['repository']),'pnpm.overrides:',list(p['pnpm']['overrides']))"
done

Observed (same spec, same gen.yaml, no edits between runs):

repository: ['url', 'type']  pnpm.overrides: ['postcss', 'vite']
repository: ['type', 'url']  pnpm.overrides: ['postcss', 'vite']
repository: ['url', 'type']  pnpm.overrides: ['postcss', 'vite']
repository: ['url', 'type']  pnpm.overrides: ['vite', 'postcss']
repository: ['type', 'url']  pnpm.overrides: ['postcss', 'vite']
repository: ['type', 'url']  pnpm.overrides: ['postcss', 'vite']

Which specific object flips varies per process invocation; the order is unsorted and variable (not alphabetical, not insertion order).

Likely cause

The order is variable rather than sorted, which rules out Go stdlib encoding/json map marshaling (it sorts map keys deterministically). Combined with the defect being confined to additionalPackageJSON-sourced nested objects while Speakeasy-native top-level keys stay stable, this points at the additionalPackageJSON merge path decoding nested objects into an unordered map[string]any and emitting them via Speakeasy's ordered JSON writer without preserving YAML insertion order or sorting. (gopkg.in/yaml.v3 can preserve order via the Node/MapSlice API; encoding/json would at least sort — neither would produce this variable order, so it appears to be in Speakeasy's own merge/emit, not a dependency.)

Impact

  • Byte-level regen-survival / idempotency CI gates false-positive on every regeneration.
  • Spec-driven regens produce churny package.json diffs unrelated to any actual API change, obscuring real changes in review.

Expected

Nested objects from additionalPackageJSON should serialize with stable, deterministic key order across runs (preserving the order declared in gen.yaml, or failing that, a deterministic sort).

Workaround

Assert package.json invariants by value rather than byte-identity in CI.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions