Check the structs packages against encoding/json - #6469
Draft
denik wants to merge 6 commits into
Draft
Conversation
encoding/json decides which fields exist on the wire, under which names, at which paths.
Every libs/structs package claims to speak that vocabulary -- structwalk enumerates it,
structaccess reads and writes it, structdiff reports changes in it -- so a disagreement is
a bug in one of them, or in the type.
The new package fills every field of a type with a non-zero value, marshals it, and
compares the result against structwalk's leaves and structaccess.Get/ValidatePath. The
test drives it off config.Resources by reflection, so a newly added resource is covered
without touching the test.
It finds two things today, both enumerated rather than fixed here:
- Eleven resource types embed a struct that declares MarshalJSON and declare none of
their own, so the embedded marshaler takes over and id, url, lifecycle and permissions
never reach the wire. Nothing marshals a config type today (bundle validate -o json
marshals the dyn tree), so this is latent.
- Free-form any fields and types that marshal themselves as a scalar (duration.Duration,
the SDK time wrapper) are never visited by structwalk, so structdiff cannot report
drift on them.
Co-authored-by: Isaac
These are the types the direct engine actually reads fields out of: the plan resolves a
${resources...} reference by walking them, the state file is the JSON encoding of
StateType, and a refresh decodes into RemoteType. A disagreement here is not latent the
way it is for the config types -- it is a field the plan cannot see or the state file
cannot carry.
assertJSONRoundTrip already covers whether a wrapper loses fields across Marshal ->
Unmarshal. This covers whether the packages and encoding/json name and reach the same
fields at all.
Both flavours pass for every registered resource once the EmbeddedSlice convention is
accounted for: __embed__ is transparent to the walkers by design, so the check follows
them rather than the literal wire key.
Co-authored-by: Isaac
The repo forbids sort.Strings in favour of the standard library's generic version.
…ape corpus
The corpus in libs/structs/internal/jsonshapes pairs a struct shape whose JSON behaviour is
easy to get wrong -- two levels of embedding, a shadowed name, a same-depth collision, a
diamond, a cyclic embed, an embed behind a nil pointer -- with the fields encoding/json
actually serializes for it. Its own test asserts those expectations against json.Marshal,
so the corpus cannot teach every consumer the same wrong answer.
Each package is then checked against it on its own terms: structaccess must read, write and
validate exactly what the wire carries, and the write assertion goes through json.Marshal so
a Set into a field the wire format ignores fails; structwalk must visit exactly those paths;
structdiff must report a change to each of them and none to a field encoding/json drops.
structpath and structtag have no corpus to check against, so they are pinned directly:
a rendered path must survive being parsed again (a map key with dots is the case that
matters), and a json tag must resolve to the name encoding/json chose for it.
Three disagreements are recorded rather than fixed, each as a ratchet that asserts the
disagreement is still present, so fixing one breaks the test and forces the entry out:
- structwalk visits every declaration of a shadowed embedded field, so it reports the
field twice while the wire format carries one value.
- structwalk and structdiff both expose an ambiguous embedded field that encoding/json
refuses to serialize, so the engine can plan an update that can never be sent.
- structaccess.Set will not descend through a nil embedded pointer, so a field
json.Unmarshal reaches by allocating it cannot be written.
Co-authored-by: Isaac
JSONLeaves had no caller once Check took the self-marshaling set from the internal helper, and KnownDivergence was never used. task fmt rewrote the reflection loop to reflect.TypeFor and Type.Fields.
From the adversarial review. Four ways the checks were weaker than they read: Filter dropped SelfMarshalingScalars from the report it returned, so the callers' check on that category could never fire and the category was silently ignored rather than reported. A known-divergence entry only filtered; nothing noticed when the underlying bug was fixed and the entry went stale. Filter now returns the entries that matched nothing and both tests fail on them. That immediately found two stale entries. Prefix coverage applied to every category, so an entry naming a field could absorb an unrelated Get or value failure at a path beneath it. It now applies only to the two walk categories, where a field lost wholesale really does take its leaves with it. The per-shape gaps asserted merely that *some* disagreement remained, which a different regression would satisfy. They now hold the exact current output, so any change in behaviour fails and the entry has to be revisited. Paths inside a free-form any field are a category of their own now, like self-marshaling scalars: structwalk does not traverse an interface and structaccess cannot validate a path through one, so the whole subtree is opaque and listing individual paths would only pin the filler's choice of map key. With that, the state and remote types need no recorded divergences at all. Co-authored-by: Isaac
denik
force-pushed
the
denik/structs-json-agreement
branch
from
September 1, 2026 16:32
1ffcd0b to
f46eea2
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Stacked on #6468.
encoding/jsondecides which fields exist on the wire, under which names, at which paths. Everylibs/structspackage claims to speak that vocabulary — structwalk enumerates it, structaccess reads and writes it, structdiff reports changes in it — so a disagreement is a bug in one of them, or in the type. Nothing checked that until now.Three layers:
bundle/config/structstestfills every field of a type, marshals it, and compares the result against structwalk's leaves and structaccess Get/ValidatePath. Driven offconfig.Resourcesby reflection, so a new resource is covered automatically.bundle/direct/dresources/structs_test.godoes the same for StateType and RemoteType — the types the plan actually reads fields out of.libs/structspackage is checked against a shared corpus of shapes that are easy to get wrong (two levels of embedding, a shadowed name, a same-depth collision, a diamond, a cyclic embed, an embed behind a nil pointer). The corpus asserts its own expectations againstjson.Marshal, so it cannot teach every consumer the same wrong answer.What it found, all recorded rather than fixed here, each as a ratchet that fails when the bug is fixed:
MarshalJSONand declare none of their own, so the embedded marshaler takes over andid,url,lifecycleandpermissionsnever reach the wire. Latent:bundle validate -o jsonmarshals the dyn tree.encoding/jsonrefuses to serialize.structaccess.Setwill not descend through a nil embedded pointer, so a fieldjson.Unmarshalreaches by allocating it cannot write.anyfields and self-marshaling scalars (duration.Duration, the SDK time wrapper) are never visited by structwalk.Reverting any commit from #6467 or #6468 turns these tests red.
This pull request and its description were written by Isaac.