Skip to content

Duplicate-name ids, a model's MAT workspace for Usage, a lone config set, and struct-array element names - #127

Merged
ww-mw merged 4 commits into
mainfrom
ids-usage-workspace-configset-and-element-names
Oct 16, 2026
Merged

ww-mw merged 4 commits into
mainfrom
ids-usage-workspace-configset-and-element-names

Conversation

@ww-mw

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

Copy link
Copy Markdown
Member

Four backlog items, one commit each. Every commit has its dist/ rebuilt (rm -rf dist && npm run build), and npm run verify passed at each one.

The ruling, for all four: "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." Themes 3, 4, 6 and 7 (C-004, C-058, F1-50, C-255 among them): "take each item's recommendation".

F1-50: two entries with the same name (98f8f7c)

Recommendation (a): "Refuse deleteNodeById and edits by id when more than one indexed node has that id, and warn at open. No id changes and no wrong-entry delete."

What was wrong: if one section held two entries with the same name, a delete by id could remove the other entry.

Root cause: a node's id is its path, so twins share one id, and so does everything inside them. DataModel.indexSource kept whichever twin was indexed last.

Fix: findNodeById, deleteNodeById, addChildTo and editProperty now refuse an id that names more than one node. deleteNodesById refuses the whole batch if any id in it is shared. At open, the file gets one warning per group of twins, with a new ParseWarningCode, 'duplicate-id'. If a host renames or removes one twin, the other is indexed again. No id changes. The check costs nothing on a well-formed file. No fixture and none of the 335 readable corpus files has twins, and reindex time on a 150,004-node dictionary is unchanged within noise. findUsages is left as it is: it reads by name, so it gives the same answer for either twin.

Tests: new test/ambiguousId.test.ts, 14 tests, run through a real session. Before the fix, 12 failed for the right reason: no warning, a node returned where null was expected, a field added, or the twin left behind could not be found. The 2 controls passed.

Changed existing test: test/publicTypeSurface.test.ts. Its exhaustive switch pins ParseWarningCode as a closed union, so it gains a 'duplicate-id' arm.

Left out (vscode, when it next bumps its core pin):

  • SlddModel.findNode's cache fallback must not hand back a twin that core refused.
  • The banner must not count duplicate-id as a part that could not be read.
  • vscode's binaryEntryScopedEdit.test.ts:651 fails against this core. That test makes twins on purpose and asserts that a remove picks the wrong one. That is exactly what the ruling removes: the remove is now refused.

C-004: a model workspace kept in a .mat (811f794)

Recommendation: "Take the additive option, with a synthetic .mdl test (WSDataSource 'MAT-File' plus a linked dictionary defining the same name) asked of both engines in usageEngines.test."

What was wrong: a model can keep its workspace in a MAT file. MATLAB looks in that file right after the model's own workspace and before the dictionary. Both usage engines looked in it last. So when the linked dictionary defined the same name, the usage went to the dictionary entry.

Root cause: both parsers list the file only in externalDataSources, so nothing marked it as the workspace.

Fix: a new field, ParsedSlx.workspaceDataSource. It is null when the workspace is not in a MAT file. The .slx, legacy XML, modern .mdl and classic .mdl readers all set it. ModelNode carries it as workspaceDataSource, and ModelSummary as the optional workspaceMatRef. UsageIndex.resolveName and the session's resolutionOrder now look in that file right after the model's own workspace. The file stays in externalDataSources, so nothing a host reads today changes. workspaceMatRef is optional, so a summary that a host builds by hand is still valid.

Tests: 21 new: usageEngines (as .slx and as a classic .mdl, asked of both engines), blockParamUsages, mdlParser, usageIndex and modelNode. Before the fix, 17 failed for the right reason. For example, signals.mat's kp gave [] where ['K'] was expected, and the dictionary's kp gave ['K'] where [] was expected. The 4 controls passed.

Changed existing tests: none. The slxModel helper in usageEngines gained an externals option, and no assertion changed.

Left out: ModelBlockNode.paramSourceId still points a block's parameter link at the dictionary. That is navigation, not usage, and it lives in an area outside this item. dex-core's usage engine needs the same rule, but that is a separate repo.

C-255: a config set or model reference index that is not a list (f8b6e34)

Recommendation (c): "Both: wrap a lone object, warn part-unreadable for any other non-array, and skip with a warning any entry whose PartName or ConfigSetName is not a string", "applied in the same change to extractModelReferences' ModelReferences".

What was wrong: a hand-edited .slx with a lone ConfigSetInfo object, or a non-string PartName, could not be opened. The same was true of ModelReferences, or a reference with no ModelName.

Root cause: both values were cast to arrays with no check, then iterated and .replaced.

Fix (SlxParser.ts only):

  • A lone object is read as a list of one.
  • Any other value that is not an array gives one part-unreadable warning on that part.
  • An absent or null ModelReferences stays silent, as it is for a model that references nothing.
  • A bad entry is skipped with a warning, and the entries around it are still read.

Tests: 37 new cases in test/parseWarnings.test.ts, run through parseSlx, parseMdl and a session open. Before the fix, 34 failed: they threw (for example "configSetInfo is not iterable", "refs.map is not a function"), or they gave no warning. The 3 controls passed: an absent, null or [] ModelReferences stays silent.

Changed existing tests: none.

C-058: element names in struct, object and enumeration arrays (2fc29bb)

Recommendation: "(a) together with the nameEditable half of (c)." Option (a): "StructNode/ObjectNode stamp String(ei+1), matching MatlabVariableNode and BaseNode.ts:486's stated convention."

What was wrong: the same MATLAB value got element ids structNd/1…12 in a text dictionary but structNd/0…11 in a binary one.

Root cause: three builders numbered elements from 0: StructNode, ObjectNode's object-array loop, and ObjectNode._addTextEnumElements. MatlabVariableNode numbered them from 1.

Fix: all three now use String(ei + 1). _subscript is unchanged, so no label changes. Comments in BaseNode, StructNode.md and ObjectNode.md were updated, and the note in test/parity/matlab/DESIGN.md that recorded the split is retired. Public effect: element ids under binary-dictionary struct arrays, 2-D struct arrays in a text dictionary, object arrays (both dictionary formats and .mat) and enumeration arrays change from …/0 to …/1. A host that saved expansion or selection by id loses it once for those rows.

Tests: new test/elementNameBase.test.ts, 29 cases on real fixtures, run through ingest. Before the fix, 24 failed, each on a 0-based name. The 5 controls passed. Reverting each site alone (StructNode, the ObjectNode loop, the text enumeration builder) fails 5, 19 and 3 cases respectively.

Changed existing tests (each pinned only the old 0-based name):

  • test/objectValueRefused.test.ts:60,64: other/widget/Parts/0 becomes Parts/1. It is the same first element.
  • test/matStructArray.test.ts:479,487,516,525,529: children[2] is the third element, so its name becomes '3'.

Left out: the nameEditable half is held for the maintainer. It rests on the premise that "a per-element field rename" happens, and this code says otherwise. StructNode._renameField renames a field on every element of the array. That is documented (StructNode.md, "the one structural change a struct array does allow"), it was a data-loss fix (cd72928), and structNode.test.ts:212 and :233 pin it. Refusing the rename would remove that feature and leave no way to rename a struct array's field. So it needs the maintainer to confirm it.

Gate

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

ww-mw added 4 commits October 16, 2026 18:40
…rn at open

A node id is its path, so two entries with one name in one section share one id,
and so does everything inside them. The session index keeps the last one indexed.
deleteNodeById, and every other operation addressed by id, then acted on whichever
twin that was, so deleting the row the user picked could delete the other entry
(F1-50). Only a malformed file holds such a pair, and both entries are kept as read.

Per the 2026-10-16 group E ruling, option (a), no id changes. Instead:
- findNodeById, deleteNodeById, addChildTo and editProperty refuse an id that
  names more than one node: the node itself, or any node above it, has a sibling
  with the same id. deleteNodesById refuses the whole batch when any id in it is
  shared, as it already does for a batch spanning two files. A host resolves a row
  through findNodeById before it edits or deletes it, so the refusal sits in that
  lookup too, not only in the session's own mutations.
- At open, the source gets one warning per group of twins, under a new
  ParseWarningCode, 'duplicate-id'. part is the entry name. A clean file still
  carries no warnings field.
- A host that renames or removes one twin through mutateSubtree or unindexSubtree
  gets the twin left behind back in the index, so the id resolves to it again.
- findUsages is unchanged. It reads by name, and twins share the name, the source
  and the section, so its answer is the same for each twin. Refusing it would also
  make a row's UsedBy cell differ inside and outside a rowsOf batch.

The check costs nothing on a well-formed file. It is gated on a set of ids that
indexing found held twice, and that set is empty unless a file has twins. None of
the 108 readable fixtures or the 335 readable corpus files has any, so no existing
file warns. Reindex time on a 150,004-node dictionary is unchanged within noise.

One existing test changes. test/publicTypeSurface.test.ts pins ParseWarningCode as
a closed union, using an exhaustive switch over its codes. The ruling adds a code,
so the switch gains the 'duplicate-id' arm.
A model can keep its workspace in a MAT file (WSDataSource 'MAT-File'). That
file is the model workspace, so MATLAB looks in it right after the model's own
workspace and before any data dictionary. Both usage engines looked in it last,
with the model's other .mat files, because both parsers list it in
externalDataSources and nothing else told it apart. So when the linked
dictionary defined the same name, the usage was credited to the dictionary
entry, which is the one definition the block does not read, and the .mat
variable showed no usage.

The parsers now also name the file on its own, as ParsedSlx.workspaceDataSource
(null when the workspace is not kept in a MAT file). This is set by the package
reader (blockDiagram.json and the legacy blockdiagram.xml) and by the classic
.mdl reader. The file stays in externalDataSources, so a host that loads the
externals still loads it, and nothing a host reads today changes.
ModelNode carries it as workspaceDataSource, and ModelSummary as the optional
workspaceMatRef. Both engines now look in that file right after the model's own
workspace: UsageIndex.resolveName and the session's resolutionOrder. A summary
or a host parse built before the field existed has none, and resolves as before.

Tests (each new one failed before the change for the reason it names):
- usageEngines: the same model as .slx and as a classic .mdl, linked to a
  dictionary that defines kp too. Both engines now credit the block to the
  .mat's kp and nothing to the dictionary's. Also the case where the settings
  part lists the same file as an external. Two controls pass before and after:
  a name only the dictionary defines, and an ordinary external .mat that still
  comes after the dictionary.
- Parser tests for workspaceDataSource: .slx, legacy XML, modern and classic
  .mdl, deduplicated with the externals, and null when the workspace is not kept
  in a MAT file.
- ModelNode and ModelSummary carry the field, by basename, and leave it out
  when there is no such file. resolveName puts it after the model's own
  workspace and before the dictionary.

No existing test changed.
…ops a model opening

C-255, per the maintainer's group E ruling of 2026-10-16 (recommendation (c), applied to
both index parts).

MATLAB always writes ConfigSetInfo (configSetInfo.json) and ModelReferences
(graphicalInterface.json) as arrays: 68 of 68 and 46 of 46 across the 151 .slx in the
repo and the corpus. A hand-edited or third-party package with another shape threw out
of parseModelParts (a lone object is not iterable and has no .map; a non-string PartName
has no .replace), so the whole .slx or package .mdl failed to open. A reference with no
ModelName also made the open throw, later, in the references section.

Now, in SlxParser.ts:
- A lone object is read as a list of one, with no warning.
- Any other non-array ConfigSetInfo (a string, number, boolean, null, or the key left
  out) is one part-unreadable warning on simulink/configSetInfo.json, and no sets are
  read. Before, the falsy ones were read silently as no sets.
- A non-array ModelReferences that is a string, number or boolean is one part-unreadable
  warning on simulink/graphicalInterface.json. Absent or null stays silent: it is the
  normal case for a model that references nothing (78 of 124 parts).
- An entry that is not an object, or that does not name both its part and its set
  (PartName, ConfigSetName) or both its block and its model (BlockPath, ModelName), is
  skipped with a part-unreadable warning. The entries around it are still read.

The XML indexes and the classic .mdl reader already skip such entries and are unchanged.

Tests: 37 new cases in test/parseWarnings.test.ts, driving parseSlx, parseMdl and a
session open. Before the change, 34 failed (a throw, or no warning) and 3 controls (an
absent, null or empty ModelReferences stays silent) passed. No existing test changed.
dist rebuilt.
…avour

C-058, per the maintainer's group E ruling of 2026-10-16: the naming half,
recommendation (a). The nameEditable half is held for the maintainer and is not
changed here.

An array element has no name of its own. Its label is its MATLAB subscript,
structNd(2,1,1), which BaseNode derives from _subscript, and that was already the
same in every flavour. Its name, and so its node id, was not. MatlabVariableNode
(a text dictionary's cdata, every .mat and model-workspace struct array) named
elements 1, 2, .... Three other builders named them 0, 1, ...:
- StructNode, for a binary dictionary's struct arrays and a text dictionary's
  2-D ones;
- ObjectNode's object-array loop, for object arrays in both dictionary flavours,
  MCOS object arrays in a .mat file, and a binary dictionary's enumeration arrays;
- ObjectNode._addTextEnumElements, for a text dictionary's enumeration arrays.
So MATLAB's one value structNd had the ids structNd/1 ... structNd/12 from the
text dictionary and structNd/0 ... structNd/11 from the binary one, and
BaseNode._slotAmongSiblings, which reads a name as its 1-based slot, missed on
every 0-based element and fell back to a scan.

All three now name element ei String(ei + 1). The subscript index stays ei, so
no label changes. Nothing in src reads an element's name except
_slotAmongSiblings, and no writer uses it.

Public effect: the node ids of those elements change by one (.../0 becomes
.../1). A host that persisted expansion or selection by id loses it once for
those rows.

Tests: new test/elementNameBase.test.ts, 29 cases, opening real fixtures
through ingest: structNd in all four cases.* flavours, ten object and
enumeration arrays, a text enumeration array against the binary one, and every
subscripted element in 13 fixtures checked against its slot. Before the change
24 failed, each on a 0-based name; the 5 controls (text, .mat and model-workspace
structNd, and the .slx and sparse slot checks) passed. Reverting each of the
three sites alone fails 5, 19 and 3 of them.

Changed tests, each pinning only the old 0-based name:
- test/objectValueRefused.test.ts: other/widget/Parts/0 becomes Parts/1 (the
  same first element), in both custom_object fixtures.
- test/matStructArray.test.ts: children[2] of a .mat Parameter array is its
  third element, now named '3' (five assertions in two tests).

Docs: BaseNode's comment on the 1-based convention, StructNode.md,
ObjectNode.md, and the DESIGN.md note that recorded the split, now retired.
dist rebuilt.
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