Skip to content

Derive a struct's fields from its elements when MATLAB writes no _fields - #40

Merged
ww-mw merged 1 commit into
mainfrom
derive-struct-fields
Sep 18, 2026
Merged

ww-mw merged 1 commit into
mainfrom
derive-struct-fields

Conversation

@ww-mw

@ww-mw ww-mw commented Sep 18, 2026

Copy link
Copy Markdown
Member

The defect

Current MATLAB no longer writes _fields into a text dictionary. Build 27.1.0.3353139 wrote it; 27.1.0.3393633 does not, and the field names live only as the keys of each _elements entry.

StructNode.parse built its children from _fields alone, so every struct in a current-build text .sldd showed no field rows at all — and because serializeElement iterates that same list, editing anything under the entry re-serialized the struct as _elements: [{}], deleting its fields from the saved file. No edit to the fields themselves was needed: any edit under the entry drops _rawInput and the re-serialize does the rest.

Measured on MATLAB's own bytes: all 8 structs in cellarr_text.sldd and cellstr_text.sldd came up empty (including struct-array elements and a struct inside a cell), while every struct in the older typed_text.sldd, nd_nested.sldd and object_props_text.sldd was correct.

Why only one of four channels broke

Four parsers build this envelope, and three already derived the list for themselves — BinarySlddParser (_fields: Object.keys(parsed[0])), McosParser, MatlabVariableNode. Only the text channel, the one that passed MATLAB's JSON straight through and trusted the key, was wrong. The channel that derived from structure survived the format change; the channel that trusted the spelling did not.

The fix

One fieldsOf helper at the point all four channels funnel through:

  • an explicit _fields still wins — it is the only statement of field order an envelope carries;
  • absent it, the names are the keys of element 0. Element 0 and not a union across elements, because a struct array shares one field list by definition, and that is the rule the binary parser already applied.

serial._fields is therefore always an array, so serializeElement, _renameField and the add/remove/restore hooks read one list that is already right rather than each re-deciding what a missing key means. addChildNode's if (!this.serial._fields) { = [] } was a latent second bug on the same line — it would have built a list holding only the newly added field — and is now unreachable.

The writer stays faithful in both directions: _fieldsDeclared records whether the input declared the key, and only then is it emitted, so a save does not add _fields back to a file MATLAB left it out of. A struct we invent (createDefault) still declares an empty one — the emit policy exists to avoid contradicting a MATLAB file, and there is none in that case.

Gate

test/structFieldsAcrossBuilds.test.ts (17 tests) asserts both builds' bytes as the same assertion — a reader is not allowed to care which build it is reading — walks the text channel against the binary one to full depth, and checks both halves of the write: every field kept, _fields not invented, _fields preserved in MATLAB's order where it existed. Reverting the derivation alone fails 11 tests.

The two cross-channel carve-outs this defect had forced in binaryWriteBackGate.test.ts are gone with it: the full-depth walk now stops nowhere (32 → 38 rows compared) and sStr's field is compared like everything else.

Two enumerations hardened in the same pass

Both provably inert against every byte MATLAB has been measured writing, and both closing a failure mode that already happened once:

  • isNumericClass is a pattern, not a list, and parseTypedValue no longer keeps a second copy of the same set in its case labels. Defect 27 was precisely that duplication — int64/uint64 missing from both copies, the value falling through to the bare-text return, the writer then spelling it Class="char". u?int(8|16|32|64) cannot omit a width.
  • IsComplex accepts "true" as well as "1", through one shared helper rather than three literal comparisons — the latitude parseTypedValue's logical arm has always taken. An unrecognized spelling would not merely mis-display; it drops the imaginary parts from the value and from the next save, which is defect 53's exact shape.

half is deliberately not in the numeric pattern. Measured: the entry site accepts any class and formats it as an integer, so Class="half">1.5 writes back 2 and a non-numeric body writes back 0, while the field site's fallback loses the class but keeps the characters. Admitting a class the save path cannot format trades a wrong type for wrong digits, so the narrower failure stays — and the field site was deliberately not aligned with the permissive entry site, which was the change this audit set out to make until the measurement said otherwise.

Verification

npm run verify clean: typecheck, build, smoke, 4907 tests passed / 26 skipped / 166 files, pack (dist-only), leak, browser-safe.

Current MATLAB no longer writes `_fields` into a text dictionary: build
27.1.0.3353139 wrote it, 27.1.0.3393633 does not, and the field names live
only as the keys of each `_elements` entry. `StructNode.parse` built its
children from `_fields` alone, so every struct in a current-build text .sldd
showed no field rows at all -- and because `serializeElement` iterates the
same list, editing anything under the entry re-serialized the struct as
`_elements: [{}]`, deleting its fields from the saved file.

Four parsers build this envelope and three already derived the list for
themselves (`BinarySlddParser` from `Object.keys(parsed[0])`, `McosParser`,
`MatlabVariableNode`), which is why only the text channel -- the one that
passed MATLAB's JSON straight through and trusted the key -- broke. Move the
decision into one `fieldsOf` helper at the point all four funnel through: an
explicit `_fields` still wins, since it is the only statement of field ORDER
an envelope carries, and absent it the names are the keys of element 0 --
element 0 and not a union, because a struct array shares one field list by
definition and that is the rule the binary parser already applied.

`serial._fields` is therefore always an array, so `serializeElement`,
`_renameField` and the add/remove/restore hooks read one list that is already
right instead of each deciding what a missing key means; `addChildNode`'s
`if (!this.serial._fields)` branch was a latent second bug on the same line
(it would have built a list holding only the new field) and is now
unreachable. The writer stays faithful both ways: `_fieldsDeclared` records
whether the input declared the key and only then is it emitted, so a save
does not add `_fields` back to a file MATLAB left it out of, while a struct
we invent ourselves still declares an empty one.

Gated by test/structFieldsAcrossBuilds.test.ts, which asserts both builds'
bytes as the same assertion, walks the text channel against the binary one
to full depth, and checks both halves of the write. The two cross-channel
carve-outs this defect had forced in binaryWriteBackGate.test.ts are gone
with it -- the walk now stops nowhere.

Harden two enumerations in the same pass, both inert against every byte
MATLAB has been measured writing:

- `isNumericClass` becomes the pattern `u?int(8|16|32|64)` and
  `parseTypedValue` stops keeping a second copy of the same list in its
  `case` labels. Defect 27 was precisely that duplication: int64/uint64
  missing from both copies, so the value fell through to the bare-text
  return and the writer spelled it `Class="char"`. A pattern cannot omit a
  width.
- `IsComplex` accepts `"true"` as well as `"1"`, through one shared helper
  rather than three literal comparisons -- the latitude `parseTypedValue`'s
  `logical` arm has always taken. An unrecognized spelling would not merely
  mis-display: it drops the imaginary parts from the value and from the next
  save.

`half` is deliberately left out of the numeric pattern. Measured: the entry
site accepts any class and formats it as an integer, so `Class="half">1.5`
writes back `2` and a non-numeric body writes back `0`, while the field
site's fallback loses the class but keeps the characters. Admitting a class
the save path cannot format trades a wrong type for wrong digits, so the
narrower failure stays.
@ww-mw
ww-mw merged commit 2f6e759 into main Sep 18, 2026
1 check passed
@ww-mw
ww-mw deleted the derive-struct-fields branch September 18, 2026 19:52
@ww-mw ww-mw mentioned this pull request Sep 18, 2026
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