Skip to content

Binary nested objects in Other, a cleared envelope property, VariantControl Description, model-reference ids, and a dist-freshness CI gate - #123

Merged
ww-mw merged 6 commits into
mainfrom
nested-objects-cleared-props-ids-and-dist-gate
Oct 16, 2026
Merged

ww-mw merged 6 commits into
mainfrom
nested-objects-cleared-props-ids-and-dist-gate

Conversation

@ww-mw

@ww-mw ww-mw commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Implements the maintainer's 2026-10-16 group E ruling.

Five items from the 2026-10-15 unfixed sweep, one commit each. The ruling, from the agenda's "DECIDED 2026-10-16, group E (the backlog)" block:

maintainer: "Agree your suggestions, use subagents, teammates, or workflow agents to handle all of them automatically. Add unit tests to lock down the fix. I pre-approve PR and merge as long as it passes all tests."

C-032 and C-199 fall under theme 2 ("follow MATLAB, and measure it first … where it is unmeasured"). F2-4, F6-1 and C-003 fall under themes 3, 4, 6 and 7 ("take each item's recommendation").

Each commit regenerates dist/ with rm -rf dist && npm run build and commits the result. npm run verify was green at each commit.

Gate at the head (1b3aeac; after the guard 31dca0c: verify 7971 passed / 26 skipped, CI green)

  • rm -rf dist && npm run build: the tree stays clean, with 0 entries in git status --porcelain --untracked-files=all.
  • npm run verify: rc 0.
    • Test files: 240 passed, 1 skipped.
    • Tests: 7,970 passed, 26 skipped. The five commits add 41 tests (5, 11, 3, 5 and 17). Verify's pass count at the five commits was 7,934, 7,945, 7,948, 7,953 and 7,970.
    • Smoke, check:pack (571 files, 836.3 kB), check:leak and check:browser are all OK.
  • The new CI step's own script ran in the clone after verify: rc 0.

C-032: the Other group shows a binary dictionary's nested object (b64fdaf)

Ruling: theme 2, follow MATLAB. Recommendation, taken: "(a). Flip the pin in complexMcosFixtures to the same Other.Table.Value and Other.Breakpoints.Value expectations the other venues use." Option (a) reads: "Unwrap only a one-element _elements … and show a multi-element one as [1xN Class]."

What was wrong. In a binary dictionary, a LookupTable's Table and Breakpoints each showed in the Property Inspector's Other group as one empty row (Other.Table: ''). The .mat and text venues list Other.Table.Value = [1+2i, 3+4i, 5+6i] and Other.Breakpoints.Value = [1, 2, 3]. An object array of more than one element showed '' in every venue. MATLAB shows the same object in every venue ([1×1 Simulink.lookuptable.Table]), and an array as [1×2 Class].

Root cause. The binary reader spells a nested object as an object array of one element: {_array_class, _dimensions: [1, 1], _elements: [{_properties}]}. piOther.asNestedObject recognised only the {_object_class, _properties} spelling, so the binary form fell through to formatOther, which returned ''.

Fix (only src/datamodel/node/piOther.ts changes):

  • A new asObjectArray claims {_array_class, _elements} when all of these hold:
    • the class is a non-empty string;
    • _array_type is not Struct, Cell or String;
    • every element is a plain object with a _properties bag and no _enumeration.
  • One element in a 1x1 shape (or with no _dimensions) is that object, and it flattens one level as before. The element's own _object_class wins over _array_class, as DataNode.elementClassOf reads it.
  • Any other claimed array shows as its summary, e.g. <1x2 Simulink.lookuptable.Breakpoint>.
  • DESIGN.md's known-limitation bullet is deleted.

Deviations, with their reasons. First, the scope said "not Struct or Cell". String is excluded too, because a string array is never an object array, and an empty one would pass the element check vacuously. Second, option (a) writes the summary as [1xN Class]. The code writes <1xN Class>, which is this repo's spelling of a summary (summaryForm, DisplayConvention.ts). The commit and test comments say the same.

Tests. test/piOther.test.ts has a new describe block:

  • A binary 1x1 Table flattens to the same rows as the {_object_class} spelling.
  • 1x2, 3x1, unshaped, 0x0 and 1x1x2 arrays show as their summaries.
  • The element's own class names it, and an empty one falls back to the array's class.
  • One level further down, a 1x1 is [Class] and a 1x3 is its summary.

All four behaviour cases failed before the change, each because the row read ''. A controls case passes before and after: Struct, Cell and String envelopes, enumerations, a non-object element and an empty class all still show ''. Ten mutants of the new code were each killed.

Existing test changed: test/complexMcosFixtures.test.ts, "a LookupTable with a complex Table presents as it does everywhere". Its binary branch pinned Other.Table: '' for complex_binary.sldd, which is exactly the behaviour the ruling removes. That branch is deleted, so binary is held to the same Other.Table.Value / Other.Breakpoints.Value as the other venues. Before the fix it fails with expected undefined to be '[1+2i, 3+4i, 5+6i]'.

Fixture census (76 fixtures, 4,216 nodes): 7 rows go, 47 appear and 14 change. Every row that goes or changes was '' before.

  • The binary LookupTable gets 18 sub-rows.
  • A one-element bus or interface Elements_internal row now lists its element's properties, in binary and text alike (.mat already did).
  • 14 multi-element rows go from '' to <Nx1 Simulink.BusElement> and similar.

No vscode test pins any of these rows.

Left out: nothing.


C-199: a cleared property under a saveobj envelope is saved as MATLAB saves one (dd03991)

Ruling: theme 2, follow MATLAB, measured first. Recommendation, taken: "(a), after one MATLAB read-back of an envelope whose Specification was written as an empty char. Defer (c) to the defect-40 work." Option (a) reads: "drop the empty only when the stored bag has no sibling for that key. When a sibling exists (a value this package wrote earlier), write the empty to the sibling and into the envelope."

What was wrong. Take a binary dictionary's VariantVariable:

  1. Set its Specification to 'B' and save. Defect 46 writes 'B' into the envelope and also as a sibling <P>.
  2. Reopen it, clear the Specification, and save again. Both copies still say 'B'.

This reader reopened the entry as 'B', and so did MATLAB (R2027a 27.1.0.3409468, measured). The same loss hit:

  • a VariantVariable created by this package, in a text dictionary too, since createDefault gives it an envelope in both formats;
  • VariantBank's Value, in both formats.

Root cause. Under an envelope, DataNode._mergeProps skipped every empty override ('', null, undefined). Usually an empty there is a default the node substitutes for a property it cannot see inside the envelope, but a real clear was skipped along with it.

MATLAB measurements (Bmain R2027a):

  • An envelope whose Specification is an empty char reads back as a 0x0 char with no error. So does one where it is a 0x0 double.
  • MATLAB itself writes the 0x0 double (Class="double" Dimension="0*0") in three cases: a default VariantVariable, one set to '', and one cleared after being 'B'.

So following MATLAB means writing [], not ''.

Fix, in _mergeProps. When the override is empty and the stored bag has no own sibling for the key (hasOwnProperty), it is still dropped, because it is a default. When a sibling is there, this package wrote it, since MATLAB never writes one beside an envelope, so the empty is a clear:

  • the sibling is deleted;
  • the envelope's field is written as [], the 0x0 double;
  • a field the envelope does not declare leaves the envelope unchanged.

After the clear, cases.sldd's aVariant holds MATLAB's own bytes again. The doc comment, the SimulinkObjectNode header and test/parity/matlab/DESIGN.md (defect 46) state the rule.

Tests (all new):

test/binaryWriteBackGate.test.ts, a C-199 describe on MATLAB's cases.sldd:

  • set to 'B', then save, reopen, clear and save: the result is byte for byte MATLAB's own aVariant, and it reopens as '';
  • _mergeProps with each of '', null and undefined drops the sibling and writes [] into a copy of the envelope.

These failed before the change: 'B' was in the file twice, the entry reopened as 'B', and the sibling was still present. Three controls pass before and after:

  • with no sibling, nothing is written and the same envelope object comes back;
  • a non-empty edit is still written to both places;
  • a text-artifact clear still round-trips.

test/newEntryEnvelopeBothFormats.test.ts, a C-199 describe:

  • a new VariantVariable (Specification) and a new VariantBank (Value), in binary and in text: set to 'x', then save, reopen, clear and save. Each ends as the envelope alone, with [] in a declared field, and reopens as ''.
  • VariantBank's undeclared Value drops its sibling and returns the same envelope object.

All five failed before the change, each with the old 'x' still written. Across the two files: 8 failed and 54 passed before the change. Four mutants were each killed: sibling kept, '' written instead of [], the sibling check removed, and null left out.

Existing tests changed: none. The binaryWriteBackGate.test.ts control for an envelope default still passes unchanged.

Left out: option (c), which decodes the envelope into node properties, is deferred to the defect-40 work, as the recommendation says.

Empty-sibling guard, after review. C-199: an empty sibling beside a real envelope value is not read as a clear (31dca0c).

What was wrong. dd03991 treated any own sibling as a sign that the value was the package's own, so an empty override over it counted as a clear. That is wrong when the sibling is itself empty and the envelope holds a real value: envelope Specification 'B' and sibling <P Name="Specification" Class="char"/>. The node reads '' from the sibling, so writing '' back changes nothing. Even so, editing only Description and saving rewrote the envelope as Class="double" Dimension="0*0", and 'B' was lost. Before dd03991, 'B' was kept.

Guard. In DataNode._mergeProps, an empty override counts as a clear only when the stored sibling is non-empty. If the override and the sibling are both empty ('', null or undefined), the override is dropped as a default, and the entry is saved as it was read. The doc comment states the rule.

No real input reaches this today. MATLAB writes no sibling beside an envelope. From v1.0.0, this package writes no empty sibling there. v0.1.7 does write one, but it writes the envelope as Name="undefined", which is not read as an envelope (traced by the correctness review).

Test. New, in the C-199 describe of test/binaryWriteBackGate.test.ts:

  1. Set cases.sldd's aVariant to 'B' and save it. Both the envelope and the sibling now hold 'B'.
  2. Blank the sibling only, then reopen. Specification reads ''.
  3. Edit Description and save.
  4. The two Specification tags must come back as read: 'B' inside the envelope, plus the empty sibling.

Fail-before. Without the guard, the only failure is the new test: 1 failed, 6 passed in the C-199 describe. It received one tag, <P Name="Specification" Class="double" Dimension="0*0"/>, where it expected [<P … Class="char">B</P>, <P … Class="char"/>].

After. The new test passes. The 11 existing C-199 tests still pass: 6 in binaryWriteBackGate.test.ts and 5 in newEntryEnvelopeBothFormats.test.ts. So a real clear (sibling 'B' or 'x') still deletes the sibling and writes the 0x0 double. npm run verify passes: 240 files, 7971 passed, 26 skipped. dist is a clean rebuild (rm -rf dist && npm run build), and only DataNode changed.


F2-4: a Simulink.VariantControl refuses a Description edit (21ae8a1)

Ruling: themes 3, 4, 6 and 7, take the recommendation. Recommendation, taken: "(c) now, since it is FIX-grade under D11. Then (b) once the maintainer rules whether 'has a Description' is decided per entry or per class." Option (c) reads: "Land VariantControlNode's refusal now (D11 names Simulink.VariantControl, and neither its doc's property table nor its schema has a Description) … Decide ObjectNode separately."

What was wrong. A VariantControl has no Description:

  • MATLAB writes none. The binary params.sldd's MyVarCtrl holds only Value, ActivationTime and ValueType.
  • Its schema has none.
  • _serializedOverrides writes back only Value.

Even so, the cell and the Property Inspector offered an editor, setProperty('Description', …) returned true, and the row showed the text. The save then dropped it.

Root cause. VariantControlNode did not override descriptionEditable, so it inherited BaseNode's true.

Fix. VariantControlNode.descriptionEditable returns false. DataNode.setProperty's existing guard then refuses the edit ("… has no Description …"), leaves the node unchanged and adds no undo step. The row's _descriptionEditable and getPropInfo's Description field are no longer editable. vscode reads _descriptionEditable from the row, so it needs no change. Simulink.VariantControl.md documents this.

Tests (all new), in test/editsTheFormatCannotKeep.test.ts:

  • On createDefault:
    • descriptionEditable, _descriptionEditable and getPropInfo(Description).editable are all false;
    • setProperty('Description') returns the refusal and changes nothing;
    • neither serializeValue nor serializeXml holds the text;
    • a Value edit is still accepted.
  • Through the session, on MATLAB's own params.sldd, text and binary:
    • editProperty on MyVarCtrl's Description returns { error: true };
    • canUndo() is false;
    • the row's Description stays '';
    • the saved file is unchanged.

All three failed before the change. The unit case failed with expected true to be false. Both session cases failed with expected true to match object { error: true }, because the edit was accepted.

Existing tests changed: none.

Left out:

  • The ObjectNode half of F2-4 (AUTOSAR.Parameter and other classes core has no node for). As the recommendation says, it waits for the maintainer's ruling on whether "has a Description" is decided per entry or per class.
  • embedded.fi belongs to the deferred ObjectNode half. A fixture has one: test/fixtures/display/dispcat_text.sldd, fi_vec. It is an ObjectNode that still accepts a Description edit: setProperty('Description', …) returns true and the row shows the text. Saving then drops it, because its bag has no Description. Both candidate answers to the per-entry vs per-class question would refuse this edit.
  • Simulink.Variant, which D11 also names. Core has no node class for it, and no fixture was checked for it.

F6-1: model references and data sources get one id per row (fbb53f6)

Ruling: themes 3, 4, 6 and 7, take the recommendation. Recommendation, taken: "(a), keyed on blockPath, mirroring ModelBlockNode's blockKey precedent. Apply the same rule to DataSourceNode using its recorded path, and land it alongside dex-core's G14 so the parity record moves with it."

What was wrong. A model that references plant.slx from two Model blocks has two reference rows, both named plant.slx, and both had the id <model>/references/plant.slx. The session index is keyed by id, so it kept only the second row, and findNodeById could never return the first. A host that keys rows and selection by id could not tell the two apart. DataSourceNode had the same collision for two sources with one basename, such as a/signals.mat and b/signals.mat.

Root cause. Neither class overrides id, so each inherited BaseNode.id (parent id + / + name), and the name is the referenced file or the basename of the source path.

Fix, after ModelBlockNode.id's precedent:

  • ModelReferenceNode.id is parent id + / + name + @ + blockPath.
  • DataSourceNode.id is parent id + / + name + @ + the recorded path.

The name, displayName, Name column and linkTarget are unchanged, so resolveLink and the resolution order behave as before. Nothing in core parses a node id.

These public ids change for every model, not only for models with duplicates. For example, mdlcases.mdl/references/mdl_child.mdl becomes mdlcases.mdl/references/mdl_child.mdl@$bdroot/Child. A host that keeps selection or expansion by node id loses it once for these rows when it updates. ModelReferenceNode.md and DataSourceNode.md describe the new ids.

Tests (all new), in test/referenceIdentity.test.ts. Each test builds a model by hand and runs it through the real parser and ingest:

  1. A .slx referencing plant from top/A and top/B: the two rows have distinct ids, spelled exactly; findNodeById returns each row with its own blockPath; flatten() has no duplicate id; the name and link target are still plant.slx.
  2. The same for a classic .mdl with two ModelReference records.
  3. A .slx whose ExternalDataSourceSettings names a/signals.mat and b/signals.mat.
  4. The same with Windows paths.
  5. Ingesting the same bytes again gives the same four ids.

All five failed before the change, each because the two rows had one id. Each override is pinned on its own: removing only the ModelReferenceNode override fails tests 1, 2 and 5, and removing only the DataSourceNode one fails tests 3, 4 and 5.

Existing tests changed: three. Each looked a row up by its old id string, which is the id the ruling removes. Each now finds the row by name and looks up its id, and what each one asserts is unchanged. They are the three the scope predicted:

  • test/resolveLink.test.ts: "follows a data-source target …" and "follows a model-reference target across the .slx/.mdl extension guess" (helper modelEntry).
  • test/usedByColumn.test.ts: "gives no cell to the nodes the column means nothing for" (helper idOf).

Left out:

  • dex-core's G14 parity record lives in another repository and is not in this PR. It copies today's ids, so it needs updating when this lands.
  • A model that records the same path twice would still get two identical ids. The ruling does not cover that case, and no fixture has it.
  • The vscode pin bump should note the node-id change in vscode's CHANGELOG.

C-003: CI fails when the committed dist is not a clean build of src (1b3aeac)

Ruling: themes 3, 4, 6 and 7, take the recommendation. Recommendation, taken: "Use the first option, with the clean rebuild. First confirm once, by hand, that a clean ubuntu build at origin/main reproduces the committed dist byte for byte, so the gate does not start red." The first option reads: "ci.yml and release.yml: after verify, rm -rf dist && npm run build && git diff --exit-code -- dist && test -z "$(git status --porcelain --untracked-files=all -- dist)"."

What was wrong. dist/ is committed, and it is what a consumer installing from git runs. Nothing checked it against src/. A commit that changed src/ without rebuilding passed CI and could be tagged and released with the old code in it.

Root cause. Verify's build overwrites dist/ in the runner and compares it with nothing.

Precondition, checked first. At main (8e33408), a fresh clone was built on a case-sensitive volume with the runner's Node 22.23.3: npm ci, then rm -rf dist && npm run build. The result matched the committed dist/ byte for byte: 568 = 568 files, no diff, no untracked file and no absolute path. CI's Linux pack line at that commit (571 files, 833.3 kB) matches the local one. There is no Linux host here, so this PR's own CI run is the first byte-level check on Linux.

Fix. ci.yml (after verify) and release.yml (after Verify, before Create GitHub Release) each get the same step, "dist/ is the build of src/":

rm -rf dist
npm run build
git status --porcelain --untracked-files=all -- dist
git diff --exit-code --stat -- dist
test -z "$(git status --porcelain --untracked-files=all -- dist)"
  • The script deletes dist/ first because tsc never deletes an output. Without that, the output of a deleted source would stay and pass.
  • The git status line names every differing file in the log. This is the scope's optional print step.
  • Running after verify means a compile or test failure is still reported under verify's own name.
  • .gitignore's note credited verify with this check. It now names the step.

Tests (all new): test/ciDistGate.test.ts, 17 tests.

  • Structure tests, on the real workflow files:
    • each file has exactly one such step;
    • the step deletes before it builds and compares after;
    • its keys are exactly {name, run}, so no if:, continue-on-error: or shell: can skip it or hide a failure;
    • it comes after verify, and in release.yml before Create GitHub Release;
    • both files have the same script;
    • a reader self-check.
  • Behaviour tests, an addition to the scope. Structure checks alone would pass a step that keeps every needle but does nothing. These tests take each workflow's own script and run it under bash -e in a throwaway git repo whose build, like tsc, never deletes. The script passes on a matching dist/. It fails, naming the file, on each of: a stale src/, an orphaned output, and a new output that was never committed.

Before the change, 16 tests failed and 1 passed (the reader self-check). Every failure was "has exactly one step that rebuilds dist/ … got +0". Ten mutants were each caught. Two examples:

  • rm -rf dist_old, which keeps every needle, reds only the orphan case;
  • dropping the test -z line reds only the uncommitted-output case.

Existing tests changed: none.

Cost: about 9 s more test time, because the behaviour cases spawn npm and git, plus one tsc run per CI job.

Left out: nothing. If this PR's Linux CI reds at the new step, the merged dist/ is stale, which is a real finding and not a gate defect.


Not in this PR

  • F2-4's ObjectNode half waits for the per-entry vs per-class ruling.
  • C-199's option (c) is deferred to defect 40.
  • F6-1's dex-core G14 parity update belongs to another repository.

ww-mw added 6 commits October 16, 2026 15:45
…, and an object array as its summary

C-032 of the 2026-10-15 unfixed sweep, decided in the maintainer's group E ruling (2026-10-16,
theme 2, "follow MATLAB", option (a)).

A binary dictionary spells a nested object as an object array of one element,
`{_array_class, _dimensions: [1, 1], _elements: [{_properties}]}`, where a .mat and a text
dictionary spell it `{_object_class, _properties}`. piOther.asNestedObject recognised only the
second, so a LookupTable's Table and Breakpoints each showed as one empty row in a binary
dictionary (`Other.Table: ''`), where the other venues list `Other.Table.Value =
[1+2i, 3+4i, 5+6i]` and `Other.Breakpoints.Value = [1, 2, 3]`. An object array of more than one
element, in any venue, showed `''` too. MATLAB's display holds the same object in every venue
(`Table: [1×1 Simulink.lookuptable.Table]`) and an array as `[1×2 Class]`.

Fix, in piOther.ts alone: asObjectArray claims `{_array_class, _elements}` when the class is a
non-empty string, the `_array_type` is not Struct, Cell or String, and every element is a plain
object with a `_properties` bag and no `_enumeration` (an enumeration keeps today's display).
One element in a 1x1 shape (or no `_dimensions`) is that object: asNestedObject returns it, with
the element's own `_object_class` winning over the array's, as DataNode.elementClassOf reads one,
and buildOtherRows flattens it one level, unchanged. Any other claimed array is its
summaryForm, `<1x2 Simulink.lookuptable.Breakpoint>`, `<0x0 C>`, at any depth; a one-element
object one level further down is its `[Class]`, as before. The scope said "not Struct or Cell";
String is excluded too, because a string array is never an object array and an empty one would
pass the element check vacuously.

Over the 76 fixtures under test/fixtures (4,216 nodes, 95 with Other rows), 7 rows go, 47 come
and 14 change, and every row that goes or changes was empty: the binary LookupTable's two empty
rows become its 18 sub-rows; a binary or text one-element bus or interface's empty
`Elements_internal` row (and a binary ValueType's `be`) lists its element's properties, as the
same one-element bus out of a .mat already did; and 14 multi-element `Elements_internal` and
`Arguments` rows, in .mat, text and binary, go from `''` to `<3x1 Simulink.BusElement>` and the
like.

Tests: test/piOther.test.ts, a new describe. A binary 1x1 Table flattens to the same rows as
the `{_object_class}` spelling; a 1x2 and a 3x1 array, an unshaped pair, a 0x0 and a 1x1x2 are
their summary; an element's own class names it, an empty one falls back to the array's, and its
sub-rows are its own; one level further down a 1x1 is `[Class]` and a 1x3 its summary. Each of
those four fails before the change (each row read `''`). A control describe passes before and
after: Struct, Cell and String envelopes carrying an `_array_class`, enumeration values of one
and two elements, a non-object element and an empty class all still show `''`. Ten mutants of
the new arms (each exclusion, the 1x1 check, the class rule both ways, the summary) are each
killed.

Existing test changed, where it pinned exactly the behaviour this removes:
complexMcosFixtures.test.ts "a LookupTable with a complex Table presents as it does
everywhere" held complex_binary.sldd to `Other.Table: ''`; the binary branch is deleted, so that
venue is held to the same Other.Table.Value and Other.Breakpoints.Value as the others (fails
before: `expected undefined to be '[1+2i, 3+4i, 5+6i]'`). DESIGN.md's known-limitation bullet
for this is deleted, and piOther.ts's header names the new form.

dist regenerated (rm -rf dist && npm run build): only piOther's three files change.

npm run verify: 7934 passed, 26 skipped.
…aves a cleared one

C-199 of the 2026-10-15 unfixed sweep, decided in the maintainer's group E ruling (2026-10-16,
theme 2, "follow MATLAB", option (a)).

Under a saveobj envelope DataNode._mergeProps dropped every empty override, because an empty
there is usually the default a node substitutes for a property it cannot see inside the
envelope. A real clear was dropped with it. Set a binary dictionary's VariantVariable
Specification to 'B' and save: defect 46 writes 'B' into the envelope and as a sibling <P>.
Reopen, clear it and save again: both copies still said 'B', and both this reader and MATLAB
(R2027a 27.1.0.3409468, measured) reopened it as 'B'. The same loss hit a VariantVariable this
package created, in a text dictionary as well (createDefault gives it an envelope in both
formats), and VariantBank's Value in both formats.

Fix, in _mergeProps: an empty override ('', null or undefined) is still dropped when the
stored bag has no own sibling for the key, because that is a default. When a sibling is there,
this package wrote it, since MATLAB never writes one beside an envelope, so the empty is a
clear. The sibling is deleted, because MATLAB writes none and this reader then reads the
node's empty default. The envelope's field is written as [], the 0x0 double. MATLAB writes
that for a default VariantVariable, for one set to '' and for one cleared after being 'B', and
it reads back as an empty char (both measured). A field the envelope does not declare leaves
the envelope untouched, as an edit does. The check uses hasOwnProperty, not `in`. After the
clear, cases.sldd's aVariant is MATLAB's own bytes again. The doc comment, the
SimulinkObjectNode header and test/parity/matlab/DESIGN.md (defect 46) state the sibling rule.

Tests, all new:
- test/binaryWriteBackGate.test.ts, a C-199 describe on MATLAB's cases.sldd. aVariant set to
  'B', saved, reopened, cleared and saved again is written byte for byte as MATLAB wrote it,
  with one Specification tag, the 0x0 double. It reopens as ''. On _mergeProps directly, each
  of '', null and undefined drops the sibling and writes [] into a copy of the envelope,
  leaving the parse state alone. These three fail before the change because the old value
  stays ('B' twice in the file, 'B' on reopen, the sibling still present). Three controls
  pass before and after: with no sibling an empty override writes nothing and returns the same
  envelope; a non-empty edit over a saved one still goes to both places; and the text
  artifact, which has no envelope here, already reopened a clear as cleared.
- test/newEntryEnvelopeBothFormats.test.ts, a C-199 describe. A new VariantVariable
  (Specification) and a new VariantBank (Value), each in binary and text, set to 'x', saved,
  reopened, cleared and saved: the entry holds the envelope alone, a declared field holds the
  0x0 double, and it reopens as ''. On _mergeProps, VariantBank's undeclared Value drops its
  sibling and returns the same envelope object. All five fail before the change, each with
  the old 'x' still written.
No existing test changes. Mutants of the change (sibling kept, '' written instead of [], the
sibling check removed, null left out) are each killed.

dist regenerated (rm -rf dist && npm run build): DataNode and SimulinkObjectNode only.
…ne to save

F2-4 of the 2026-10-15 unfixed sweep, decided in the maintainer's group E ruling (2026-10-16,
theme 4, recommendation (c)). Decision D11 (2026-10-13) says an entry-level Description edit on
a class whose file has no Description is refused.

A VariantControl has no Description. MATLAB writes none for it (the binary params.sldd's
MyVarCtrl holds Value, ActivationTime and ValueType), its schema (classes/variantControl.json)
has none, and VariantControlNode._serializedOverrides writes back the Value alone. But the
node did not override descriptionEditable, so it inherited BaseNode's true. The cell and the
Property Inspector offered an editor, setProperty('Description', ...) returned true, the row
showed the text, and the save dropped it.

Fix: VariantControlNode.descriptionEditable is false. The row's _descriptionEditable and
getPropInfo's Description field are now not editable. setProperty passes a non-Value property
to DataNode.setProperty, whose existing guard refuses it ("A <kind> has no Description in a
dictionary ..."), with the node unchanged and no undo step. vscode reads _descriptionEditable
from the row, so it needs no change. Simulink.VariantControl.md says so.

The ObjectNode half of F2-4 (AUTOSAR.Parameter and other classes core has no node for) is not
in this change. It waits for the maintainer's ruling on whether "has a Description" is decided
per entry or per class.

Tests, all new, in test/editsTheFormatCannotKeep.test.ts ("a Description the entry has
nowhere to put"):
- On VariantControlNode.createDefault: descriptionEditable, the row's _descriptionEditable and
  getPropInfo(PropDescription).editable are false. setProperty('Description') returns the
  refusal and changes nothing, serializeValue and serializeXml do not hold the text, and a
  Value edit is still taken.
- Through the session, on MATLAB's params.sldd (text and binary): editProperty on MyVarCtrl's
  Description returns { error: true }, canUndo() is false, the row's Description stays '', and
  the saved file is the same as before.
All three failed before the change: descriptionEditable was true, and editProperty returned
true. No existing test changed.

dist regenerated (rm -rf dist && npm run build): only dist/datamodel/node/data/VariantControlNode.*.
…e block or the path

F6-1 of the 2026-10-15 unfixed sweep, decided in the maintainer's group E ruling (2026-10-16,
theme 4, recommendation (a)).

A model that references plant.slx from two Model blocks has two reference rows, both named
plant.slx, since the name is the file the link follows. ModelReferenceNode inherited
BaseNode.id (parent id + '/' + name), so both rows had the id <model>/references/plant.slx.
The session index is keyed by id, so it kept only the second, and findNodeById could never
return the first. A host keys rows and remembered selections by id too, so it could not tell
the two apart. DataSourceNode had the same problem: its name is the basename of the recorded
path, so a model whose sources are a/signals.mat and b/signals.mat had two rows with the id
<model>/dataSources/signals.mat.

Fix, after ModelBlockNode.id (which keys a block on its SID for the same reason):
- ModelReferenceNode.id is parent id + '/' + name + '@' + blockPath. A block path is unique
  per Model block.
- DataSourceNode.id is parent id + '/' + name + '@' + fullPath (the path the model recorded).
name, displayName, the Name column and the Value cell's linkTarget are unchanged, so
resolveLink and DataModel's resolution order (both read the name) behave as before. Nothing
in core parses a node id.

These ids are public and they change for every model, not only for models with duplicates.
For example, at the reference fixtures:
- mdlcases.mdl/references/mdl_child.mdl is now mdlcases.mdl/references/mdl_child.mdl@$bdroot/Child
- mdlcases.mdl/dataSources/mdlparams.sldd is now
  mdlcases.mdl/dataSources/mdlparams.sldd@mdlparams.sldd
- model_with_refs.slx/references/plant.slx is now
  model_with_refs.slx/references/plant.slx@ctrl/plant
A host that keeps selection or expansion by node id loses it once for these rows when it
updates. ModelReferenceNode.md and DataSourceNode.md describe the new id.

New tests in test/referenceIdentity.test.ts. Each model is built by hand and goes through the
real parser and ingest:
- A .slx whose graphicalInterface.json references plant from top/A and top/B: the two
  reference nodes have distinct ids (spelled as above), findNodeById returns each one with
  its own blockPath, and flatten() has no duplicate id. Name and link target are still
  plant.slx.
- The same for a classic .mdl with two ModelReference records ($bdroot/A, $bdroot/B).
- A .slx whose ExternalDataSourceSettings.xml names a/signals.mat and b/signals.mat, and one
  with Windows paths: two distinct ids, each findable, both rows still named signals.mat
  with link target signals.mat.
- Ingesting the same bytes again gives the same four ids.
All five failed before the change, each because the two rows had the same id.

Existing tests changed: only the three that looked up these rows by the old id string, which
is the id the ruling removes. They now find the row by name and look up its id:
- test/resolveLink.test.ts, "follows a data-source target ..." and "follows a
  model-reference target ..." (new helper modelEntry).
- test/usedByColumn.test.ts, "gives no cell to the nodes the column means nothing for"
  (new helper idOf).
What each test asserts is unchanged.

dist regenerated (rm -rf dist && npm run build): only DataSourceNode.* and
ModelReferenceNode.* change.
dist/ is committed because consumers install this package from git, so the
committed dist/ is what they run. Until now nothing checked it against src/:
verify's build overwrites dist/ in the runner and compares it with nothing,
so a commit that changed src/ without rebuilding passed CI and could be
tagged and released with the old code in it.

ci.yml and release.yml now run one more step after verify, "dist/ is the
build of src/". It deletes dist/, builds it again, prints any file that
differs, and fails if a tracked output changed or was not rebuilt (git diff
--exit-code) or if the build made an output nobody committed (an untracked
file under dist/). It deletes first because tsc never deletes an output, so
an incremental build would leave the output of a deleted source in place
and pass. In release.yml it runs before Create GitHub Release, so a tag with
a stale dist/ never becomes a Release. The .gitignore note credited verify
with this check; it now names the step.

test/ciDistGate.test.ts reads both workflows and checks that the step is
there, after verify (and before the Release is created), with the same
script in both, and with no if:, continue-on-error: or shell: that could
skip it or ignore it. It then runs each workflow's own script under bash -e
in a throwaway git repository whose build, like tsc, never deletes. The
script passes on a dist/ that matches and fails, naming the file, when src/
changed without a rebuild, when dist/ keeps the output of a deleted source,
and when a new output was never committed.

Before adding the gate, a clean build of main (8e33408) from a fresh clone,
on a case-sensitive volume with the runner's Node 22.23.3, reproduced the
committed dist/ byte for byte: 568 files, no difference, no untracked file.
CI's own pack line on Linux at that commit (571 files, 833.3 kB) matches the
local one.

No source change, so dist/ is unchanged.
Follow-up to C-199 (dd03991), from PR #123's correctness review (non-blocking).

dd03991 reads "the stored bag has a sibling for this key" as "this package wrote the value,
so an empty override is a user clear". That is false when the sibling is itself empty beside
a real envelope value: envelope Specification 'B', sibling <P Name="Specification"
Class="char"/>. The node read '' from the sibling, so its '' is no change. Edit only
Description and save, and the envelope was rewritten as Class="double" Dimension="0*0",
losing 'B'; the base before dd03991 kept it.

Fix, in DataNode._mergeProps: an empty override is a clear only when the stored sibling is
non-empty. An empty override over an empty sibling ('', null or undefined) is dropped as a
default, so the entry is written as it was read. The doc comment states the rule.

No writer leaves that shape today: MATLAB writes no sibling beside an envelope, and from
v1.0.0 this package writes no empty one there (the review traced v0.1.7, which does, and
found it writes the envelope as Name="undefined", which is not read as an envelope).

Test, new, in test/binaryWriteBackGate.test.ts's C-199 describe: cases.sldd's aVariant set
to 'B' and saved (envelope and sibling both 'B'), the sibling alone blanked, reopened (reads
''), Description edited and saved. The two Specification tags must be written as read: 'B'
in the envelope and the empty sibling. Before the change it fails with one tag,
Class="double" Dimension="0*0". The existing C-199 tests still pass, so a real clear (sibling
'B' or 'x') still deletes the sibling and writes the 0x0 double.

dist regenerated (rm -rf dist && npm run build): DataNode only.
@ww-mw
ww-mw merged commit c3dee1a into main Oct 16, 2026
4 checks passed
@ww-mw
ww-mw deleted the nested-objects-cleared-props-ids-and-dist-gate branch October 16, 2026 21:15
ww-mw added a commit that referenced this pull request Oct 16, 2026
main's #123 reached main after this branch's last green CI, and the merge
ref failed 4 tests. Neither failure is a defect in either side: each test
pinned something the other PR changed on purpose.

- test/newEntryEnvelopeBothFormats.test.ts (from #123, C-199). Three cases
  set a Value on a new Simulink.VariantBank to exercise a field the saveobj
  envelope does not declare. C-168 removes exactly that Value (MATLAB's
  class has none), so the set is now refused. The cases move to the one
  vehicle a new entry still has for that shape: Simulink.VariantConfigurations'
  Value, written by createDefault as a sibling beside an envelope that does
  not declare it, in both formats. The assertions are unchanged; the
  undeclared case also states its premise (the envelope's _fields has no
  Value), so a vehicle that stops being one fails rather than passing
  vacuously. That Value is unmeasured against MATLAB and may be invented
  too; the comment says so. With #123's _mergeProps clear reverted (src
  only), all five C-199 cases here fail with the old sibling still written.

- test/signalEnumProps.test.ts (this branch, C-030). The Property
  Inspector's Other group for sigC in a binary dictionary now lists
  LoggingInfo's seven properties, because #123's C-032 flattens a binary
  1x1 object one level as the same object out of a .mat. The text
  dictionary's LoggingInfo has no properties, so it stays one row. The
  expectation follows #123 per format. With C-030's src reverted the case
  still fails (at its first assertion, the PI row's edit); with C-032's
  piOther change reverted it fails at the updated line.

No src change; dist rebuilt clean after the merge with no difference.
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