Skip to content

fix(features): keep JS property order in metadata, options and compose env - #75

Merged
aledbf merged 2 commits into
mainfrom
sorted-keys-to-js-order
Nov 13, 2026
Merged

aledbf merged 2 commits into
mainfrom
sorted-keys-to-js-order

Conversation

@aledbf

@aledbf aledbf commented Nov 13, 2026 •

Copy link
Copy Markdown
Member

What TS does

The reference CLI (v0.90.0) writes these objects as JavaScript enumerates them: integer-index keys ascending, then the others in the order each was first set.

  • devcontainer.metadata label: getDevcontainerMetadata puts the base image's entries in the label as JSON.parse read them from its label, and the config's entry is pick(devContainerConfig.raw, …). So each containerEnv keeps its own declaration order.
  • read-configuration featuresConfiguration: a Feature's value is the user's options object from devcontainer.json, or the dependsOn options.
  • features resolve-dependencies: installOrder[].options is features[0].value, the same object.
  • Compose override (generateFeaturesComposeOverrideContent, dockerCompose.ts): the override writes environment from Object.keys(mergedConfig.containerEnv). That object is Object.assign({}, ...entries.map(e => e.containerEnv)), so a key stays where it first appeared and takes the value of the last entry that sets it.

Before / after

Config: containerEnv: {"ZED":"1","10":"x","ALPHA":"2","2":"y"}; Feature options: {"zz":"1","10":"t","a":"b","2":"s"}; base image label: containerEnv: {"ZB":"1","10":"b","AB":"2","2":"a"}.

Place Before (Go) After (= TS)
label containerEnv (base entry) {"10","2","AB","ZB"} {"2","10","ZB","AB"}
label containerEnv (config entry) {"10","2","ALPHA","ZED"} {"2","10","ZED","ALPHA"}
featuresConfiguration value {"2","10","a","zz"} (sorted) {"2","10","zz","a"}
resolve-dependencies options {"10","2","a","zz"} {"2","10","zz","a"}
compose override environment (base ZB,10,AB + config ZED,AB,2,ALPHA) 10, 2, AB, ALPHA, ZB, ZED 2, 10, ZB, AB, ZED, ALPHA

How

The change reuses the #68 machinery and adds no second ordering mechanism:

  • imagemeta.Entry.ContainerEnvOrder is recorded on decode (jsonc.MemberNames). The config entries also set it, from cfg.ContainerEnvOrder (configMetadataEntry) and from the loader's order (configLabelEntry). It survives hostMetadataSubstitution, which round-trips the entries through maps. Entry.MarshalJSON writes containerEnv in features.PropertyOrder.
  • MergeConfiguration collects MergedConfig.ContainerEnvOrder with features.KeyOrder, which keeps the first position. The compose override iterates over that order.
  • The new features.JSValue(v, declared) returns toJS output with a map's keys in PropertyOrder. Feature.MarshalJSON uses it for value with ValueOrder, and resolve-dependencies uses it for options.
  • docs/DIVERGENCES.md: the label bullet now says that containerEnv keeps TS order.

Tests

Each of these fails before the fix.

  • ts-oracle (internal/imagemeta/testdata/ts-oracle/imagemeta.json, regenerated with task testdata:ts-oracle):
    • a getDevcontainerMetadataLabel case with per-entry containerEnv orders that include integer-like keys;
    • a new oracle function mergeConfigurationContainerEnv (Object.entries of the merged containerEnv) with a case where a key keeps its first position.
  • Unit tests:
    • TestFeatureMarshalJSONWritesTheValueInPropertyOrder
    • TestJSValueOrdersAMapsKeys
    • TestMetadataEntriesKeepTheContainerEnvOrder (configLabelEntry, host substitution, configMetadataEntry)
    • TestGenerateComposeFeaturesOverrideWritesTheMergedEnvInPropertyOrder (label decode, then merge, then override)
  • Parity cases (each fails against an origin/main binary and passes now):
    • contract read-configuration.features-configuration-value-key-order-success (key_order on the Feature's value)
    • contract features.resolve-dependencies-option-key-order-success (stdout_text, user options and dependsOn options)
    • runtime build.metadata-label-container-env-order-success (image label)
    • runtime up.compose-container-env-order-success (override environment, container label, image label)
  • TestEntryKeyOrdersNameEveryProperty now skips json:"-" fields; ContainerEnvOrder is not a label property.

Checks

  • go build ./...: pass
  • go test ./internal/features ./internal/imagemeta ./internal/config: pass. ./internal/cli: pass, except parity contract cases that fail only on this machine because a stray /tmp/.git makes Go put workspaceFolder at /workspaces/tmp. Those are the three read-configuration.features-configuration-* cases below, plus override-config-*, key-order and json-stringify-escapes. They all differ only on workspace.workspaceFolder.
  • go tool task lint: pass
  • BASE=origin/main go tool task mutate: after the second commit, 160 refused, 0 survived, 7 did not build. The first commit's CI mutate run found a survivor: deduplicating the merged order in MergeConfiguration was redundant with PropertyOrder. The second commit removes that step.
  • TMPDIR=/var/tmp/… go tool task test:race: pass, with two exceptions:
    • TestEntryKeyOrdersNameEveryProperty failed on the new json:"-" field. It is fixed in this PR, and go test -race ./internal/imagemeta passes.
    • TestMetadataInteropGoRoundTrip failed on docker inspect. Its fixed tag dcc-interop-rw006:test is shared with other agents on this Docker host. It passes when rerun alone.
  • go tool task testdata:ts-oracle:check: pass
  • go tool task parity:contract: pass, except the /tmp/.git cases read-configuration.features-configuration-local-feature-{key-order,metadata}-success and the new read-configuration.features-configuration-value-key-order-success. The new case fails for the same workspaceFolder reason and passes when its workspace is outside /tmp. CI has no /tmp/.git.
  • go tool task spec:compliance: pass
  • PARITY_COMMAND=build,up runtime parity (run with go test … -timeout 180m, because the task's 60m limit expired on this shared, loaded Docker host): 130 pass, 26 fail. Of the failures:
    • 16 differ only on remoteWorkspaceFolder/workspaceFolder because of the local /tmp/.git (up.dotfiles-local-*, up.workspace-secrets, up.config-file-fspath-*, up.override-config-scheme, up.include-configurations-key-order, up.image-with-parallel-initialize-command, up.container-entrypoint-and-command, up.read-only-workspace-dockerfile).
    • The other 10 were timeouts or fixed-name compose projects colliding with other agents ("container is marked for removal", "No such container"). All 10 pass when rerun alone: up.compose-dockerfile-with-features-reuse-stopped-container-{with,without}-persisted-overrides, up.compose-with-name{,-custom-yaml,-devcontainer-env}, up.image, up.compose-dockerfile-with-features, up.compose-{dockerfile-without,image-with}-features-omit-config-remote-env-from-metadata, build.no-cache-features, build.cache-to-local.
    • The new build.metadata-label-container-env-order-success and up.compose-container-env-order-success pass.
  • go tool task test:e2e: pass
  • CI on this PR: every check passes, including e2e, parity, parity-runtime (0/1) and mutate.

🤖 Generated with Claude Code

aledbf and others added 2 commits November 13, 2026 02:40
…e env

The reference CLI writes these objects in JS property order (array-index
keys ascending, then the others where each was first set); this CLI
sorted them:

- each entry's containerEnv in the devcontainer.metadata label, both a
  base image's entries as its label lists them and the configuration's
  as devcontainer.json declares it;
- a Feature's value in read-configuration's featuresConfiguration;
- the options of features resolve-dependencies' installOrder;
- the environment of the compose override, which is the merged
  containerEnv, where a key keeps the position it first had
  (Object.assign over the base image, Features and configuration).

An imagemeta.Entry now records its containerEnv's member order when it
is decoded or built from the configuration, keeps it through host
substitution, and MergeConfiguration carries the merged order. A
Feature's value and the resolve-dependencies options are written
through features.JSValue with the recorded option order.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
…rder

Every reader of MergedConfig.ContainerEnvOrder goes through
features.PropertyOrder, which already keeps a key's first position, so
deduplicating it in MergeConfiguration had no effect any test could see.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@aledbf
aledbf merged commit 908b5e4 into main Nov 13, 2026
13 checks passed
@aledbf
aledbf deleted the sorted-keys-to-js-order branch November 13, 2026 11:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant