Skip to content

fix: a malformed data.json no longer costs you the choices QuickAdd could not draw - #1629

Open
chhoumann wants to merge 8 commits into
masterfrom
chhoumann/1608-macro-data-robustness
Open

fix: a malformed data.json no longer costs you the choices QuickAdd could not draw#1629
chhoumann wants to merge 8 commits into
masterfrom
chhoumann/1608-macro-data-robustness

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Six issues in one pass, all from the same root: QuickAdd renders data.json through filters, and then persists what it rendered. Anything a filter dropped is deleted; anything a guard could not see is skipped and flagged done forever. This is the successor to #1593/#1616 and follows its idiom - READ accessors are total and never persist their coercion, WRITE paths are guarded so a malformed value survives on disk, and the one seam allowed to REPAIR does it by re-keying, never by dropping.

Every issue was reproduced on master in an isolated vault before anything was designed, and every fix re-verified there afterwards.


#1608 - the headline: a reorder deleted choices

ChoiceList filtered the rendered list to entries it could key, under a comment saying it was a "render-time view only. Nothing here rewrites the tree." It was not: the same filtered array seeded the drop zone and was what the persist path wrote back. So the first drag or ArrowDown committed the filtered list.

For a null hole that is harmless. But the filter also dropped any entry whose id was not a non-empty string, and

{ "name": "Daily note", "type": "Template", "templatePath": "Templates/Daily.md", "id": 12 }

is a complete, working choice - quickadd:list shows it, the palette runs it - whose id became a JSON number through a hand-edit, a script or a merge. It was invisible in settings, so the user could not miss it, and one keypress deleted it with no prompt and no undo.

Proven live before the fix: 5 choices in data.json, 4 rows rendered, one ArrowDown, 4 choices on disk.

Fix: repair at the editor seam instead of hiding, mirroring what #1593 shipped for commands. normalizeChoiceList gives an unkeyable entry a fresh uuid and keeps it, so it becomes visible, editable and deletable for the first time; only a hole is dropped. Ids are de-duplicated across the whole tree, because choice ids are globally unique (the command registry keys on choice:<id>) unlike command ids. Nothing is persisted by the repair - it reaches disk with the user's first ordinary edit.

Before After
before after

"Daily note" is missing on the left. It is also the folder that says it could not be read (#1611, below).

#1611 - an empty folder that claimed to be unreadable

hasUnreadableChildren documented itself as true only for a value that could still be carrying choices, then returned true for "", 0 and false - as empty as the null and {} it correctly returned false for. Four surfaces read it, so such a folder got the scary notice, lost its drop target, and got the scarier delete confirmation. Visible in the pair above.

#1610 - migrations reporting complete over data they never saw

A migration runs once and is flagged complete forever, so one that walks past a subtree it could not read leaves it un-migrated permanently - repairing data.json afterwards does not help. The guard that exists to prevent that asked the wrong question in both directions:

  • Under-blocking. It looked at Multi.choices only. A Capture inside "commands": {"0": {"type": "NestedChoice", ...}} was invisible to walkAllChoices, and the guarded migrations reported complete anyway. Two migrations had no guard at all, and both translate the legacy openFileInNewTab keys that runtime ignores - so a hidden choice loses its "open in new tab" preference for good.
  • Over-blocking. It returned true for any non-array choices, including undefined and {}, which carry nothing. One folder with no choices key kept removeMacroIndirection pending forever: legacy macros never embedded, never removed, and every legacy macro choice in the vault dead, because nothing at runtime resolves a macroId.

Fix: the guard is now derived from the walk it has to agree with - walkSettings reports the containers it stepped over, and settingsTreeHasUnreadableData is that report. Each migration asks the question its own traversal can answer: the three that recurse Multi.choices themselves keep the folders-only question, because blocking removeMacroIndirection on a macro.commands it never descends is pure cost for the most expensive kind of pending.

Two things fixed alongside, because the guard is worthless without them: settings.macros is untrusted too and ?? [] let {"0": ...} through into for..of (three migrations threw macros is not iterable - caught, reverted, and a 15-second "please create an issue" notice on every launch); and removeMacroIndirection holds the only root write among the migrations, so it gets its own Array.isArray guard - a readability predicate must never be the only thing between a corrupt value and a TypeError.

#1609 - a duplicated macro shared the original's nested ids and secrets

regenerateIds re-ided the macro and its top-level commands only. Nothing crashed, but buildUserScriptSecretId keys a stored secret on command.id, and migrateUserScriptSecretSettings re-adopts an existing secret by that id - so a duplicated macro's nested UserScript command adopted the original's secret slot, and setting the API key on the copy set it on the original.

The fix uncovered a second route to the same symptom that would have survived it: the secret sanitizers reached choice.macro.commands behind an isRecord gate, and isRecord accepts an array - so for macro: [ ... ] they walked nothing and the copy kept the original's literal secretRef. All three readers now resolve through macroCommandsValueOf.

The regression test asserts the symptom - the copy's nested script resolves to a different secret slot - not just that the ids differ.

#1612 - deleting an unreadable macro said nothing

A folder whose children could not be read warned about it; a macro whose commands could not be read got only the generic line. Since #1593 the builder reports that state and refuses to overwrite it, and then the one screen about to destroy it stayed quiet. Both surfaces now read the same predicate through the same accessor.

Before After
before after

#1613 - a command could be dragged out of a branch editor into the macro builder

The branch editor opens over the still-open macro builder, both mounted a CommandList, and both registered their drop zone under the same type. svelte-dnd-action groups zones by type and hit-tests them geometrically - a modal backdrop shields nothing - and the two lists overlap almost exactly.

Proven live: with the Then-branch editor open, dragging its one command past the branch list's own bottom edge (still inside the modal) moved it into the builder underneath. Both editors then rendered a list that disagreed with the stored macro.

Before After
before after

Left: "Wait for 250 ms" has escaped the Then branch into the macro's top level, while the conditional still reports "Then: 1" - the same command in two places, which is the each_key_duplicate shape #1593 is about. Right: unchanged.

Each list now gets its own zone type, so no two are ever in the same group.


Tradeoffs worth naming

A repaired id is a fresh uuid, never String(oldId). Coercing 12 to "12" is tempting - it would keep quickadd:choice:12 alive - and is wrong twice: ids are compared with === in getChoice, so a persisted 12 -> "12" silently breaks a ChoiceCommand{choiceId: 12} (MacroChoiceEngine matches /not found/i and skips the step), and "that string is free" can only mean "not seen yet" during a pre-order walk, so it can also steal a healthy later sibling's id. A stored reference to a malformed id does break under this change - but the behaviour it replaces deleted the choice, which broke the same reference and lost the choice with it.

The repaired choice's command is registered, the stale one is not removed. Nothing is persisted at seed time, so the old id is still what getChoice resolves; the new entry reports an honest "Choice not found" until the first save, after which they swap. The stale entry goes on the next reload. Verified live: three settings-tab opens produce one stable id and two palette entries, not four.

dropFromOthersDisabled was the first #1613 fix and is worse - only visible live. The target does refuse, but the library then re-dispatches the origin's items twice, once with its shadow placeholder and once without, and QuickAdd's stripShadow (#1244/#883) drops the placeholder the library goes on to measure. The drag ended in an uncaught getBoundingClientRect TypeError with the command gone from the source list.

A duplicated macro whose commands is {"0": ...} keeps the original's refs and ids. Deliberate faithful carry-through, and unreachable in-app: the engine throws, the builder shows the read-only card, the exporter skips it, and #1612's warning now names it before deletion. Stripping the refs without re-iding would be strictly worse.

Migration cost. For a vault that stays pending, this is one data.json write per launch, not three - the two file-opening migrations no longer save themselves, since migrate.ts already saves once after the run. That matters because whole-file writes are what feeds Obsidian Sync's last-write-wins, the mechanism this corruption class comes from.

Validation

  • pnpm run build / tsc --noEmit / pnpm run check / pnpm run lint: clean.
  • 4778 tests pass. Every new ratchet was checked by reverting its own hunk and confirming it fails - including two of my own tests that an adversarial pass showed were asserting nothing (c.fileOpening ?? c.type falls back to the truthy string "Template", and reverting [BUG] The macro builder and the conditional branch editor share a drop zone type, with no cross-zone guard #1613's zone type left the whole suite green).
  • Live in this worktree's isolated vault, on the final build: all five real choices render, a reorder puts all five on disk with their payloads intact, the two unreadable containers ("commands": {"0": ...} and choices: "") come back byte-identical, holes are dropped, the delete dialog names the unreadable macro, the cross-zone drag no longer crosses, an ordinary reorder inside a command list still works, and dev:errors is clean throughout.

Filed rather than grown into this diff

#1625 (a new choice is discarded if a settings write lands while its builder is open - pre-existing, and the reason the seam is memoized on the raw reference), #1626 (deleting a folder is still silent about unreadable data inside it - needs the traversal lifted out of src/migrations/), #1627 (removeMacroIndirection never sees a legacy macroId choice nested inside macro commands).

Closes #1608
Closes #1609
Closes #1610
Closes #1611
Closes #1612
Closes #1613

Summary by CodeRabbit

  • Bug Fixes
    • Prevented drag-and-drop actions from affecting separate command lists.
    • Repaired malformed or duplicate choice IDs so entries remain visible, stable, and reorderable.
    • Improved handling of corrupted or unreadable settings during migrations, preserving data for recovery and avoiding unsafe writes.
    • Made macro duplication safer by maintaining unique nested IDs and removing copied secret references.
    • Improved deletion warnings when macro commands cannot be read.
  • Reliability
    • Added broader coverage for malformed choice, command, migration, and drag-and-drop scenarios.

… this"

`hasUnreadableChildren` documented itself as true only for a value that could
still be CARRYING choices, and then returned true for `""`, `0` and `false` -
which are as empty as the `null` and `{}` it correctly returns false for.

Four surfaces read that predicate, so a folder whose `choices` was `""` showed
"QuickAdd couldn't read this folder's contents", lost its drop target, and got
the scarier delete confirmation - for a value holding nothing at all.

The rule the doc describes is already implemented one type over, by
`isUnreadableCommandList` (#1593). Rather than a shared module, this splits out
the value-level sibling `isUnreadableChoiceList` and keeps the two as the pair
the repo already uses for this (`isChoiceLike`/`isCommandLike`,
`hasChildChoices`/`hasCommandList`), with a table-driven test over the union of
both shape lists as the ratchet that they answer identically. That test is what
makes the "these surfaces cannot disagree" claim true rather than aspirational.

The `""` / `0` / `false` rows join both shared shape lists, so every resilience
test that sweeps the malformed tree now attacks them too.

Closes #1611
A migration runs once and is then flagged complete forever, so one that walks
past a subtree it could not read leaves that subtree un-migrated permanently -
repairing data.json by hand afterwards does not help, because the flag is set.

`treeHasUnreadableChildren` existed to prevent exactly that, and asked the wrong
question in two directions at once:

UNDER-blocking. It looked at `Multi.choices` only. A Capture living inside
`"commands": {"0": {"type": "NestedChoice", ...}}` was invisible to
`walkAllChoices`, and the four migrations that guard reported complete anyway.
`backfillFileOpeningDefaults` and `migrateFileOpeningSettings` had no guard at
all, and both translate the legacy `openFileInNewTab` keys that runtime ignores -
so a hidden choice loses its "open in new tab" preference for good.

OVER-blocking. It returned true for ANY non-array `choices`, including
`undefined`, `null` and `{}`, which carry nothing. One folder with no `choices`
key therefore kept `removeMacroIndirection` pending forever: legacy macros never
embedded, never removed, and every legacy macro choice in the vault dead, because
nothing at runtime resolves a `macroId`.

The guard is now derived from the walk it has to agree with - `walkSettings`
reports the containers it stepped over, and `settingsTreeHasUnreadableData` is
that report - so the two cannot drift. `walkChoice` resolves through
`macroCommandsValueOf` like every other consumer, which also closes the
array-valued `macro` hole where `.commands` reads as `undefined`, walks nothing,
and looks perfectly readable.

Each migration asks the question its own traversal can answer. The three that
recurse `Multi.choices` themselves keep the folders-only question; blocking
`removeMacroIndirection` on a `macro.commands` it never descends would be pure
cost for the most expensive kind of pending.

Two things found while doing this and fixed here because the guard is worthless
without them:

- `settings.macros` is as untrusted as the rest of data.json, and
  `settings.macros ?? []` passes `{"0": {...}}` through (not nullish). Three
  migrations then threw `macros is not iterable` - caught, reverted, and
  reported with a 15-second "please create an issue" notice on every launch.
  They now read through `rootMacrosOf` and only write back a real array.
- `removeMacroIndirection` holds the only root WRITE among the migrations
  (`settings.choices.push`). It gets its own `Array.isArray` guard: a
  readability predicate must never be the only thing between a corrupt value and
  a TypeError.

The two file-opening migrations no longer call `saveSettings()` themselves.
migrate.ts already saves once after the whole run, and now that these can stay
pending, a per-migration write would be a full data.json rewrite on every launch,
straight into Obsidian Sync's whole-file last-write-wins.

Closes #1610
…t render

ChoiceList filtered the rendered list to entries it could key, and a comment
above that filter said it was a "render-time view only. Nothing here rewrites the
tree." It was not: the same filtered array seeded the drop zone AND was what the
persist path wrote back. So the first drag or ArrowDown committed the filtered
list, and every entry the filter had dropped was gone from data.json.

For a `null` hole that is harmless - it carries nothing. But the filter also
dropped any entry whose id was not a non-empty string, and

  { "name": "Daily note", "type": "Template", "templatePath": "...", "id": 12 }

is a complete, working choice - runnable from the palette, listed by the CLI -
whose id was written as a JSON number by a hand-edit, a script or a merge. It was
invisible in the settings list, so the user could not miss it, and one reorder
deleted it with no prompt and no undo. Verified end to end in a live vault: five
choices in data.json, four rows rendered, one ArrowDown and the fifth is gone
from disk.

Repair rather than hide, mirroring what #1593 shipped for macro commands.
`normalizeChoiceList` runs at the ChoiceView seam: an entry that cannot be keyed
gets a fresh uuid and is KEPT, so it becomes visible, editable and deletable for
the first time; only a hole is dropped. Ids are de-duplicated across the whole
tree, since choice ids are globally unique (the command registry keys on
`choice:<id>`) unlike command ids. Nothing is persisted by this - the repair
reaches disk with the user's first ordinary edit.

A repaired id is always a fresh uuid, never `String(oldId)`. Coercing looks
tempting because it would keep `quickadd:choice:12` alive, and it is wrong twice:
ids are compared with `===` in `getChoice`, so a persisted 12 -> "12" silently
breaks a `ChoiceCommand{choiceId: 12}` (MacroChoiceEngine matches /not found/i
and skips the step), and "that string is free" can only mean "not seen YET"
during a pre-order walk, so it can also steal a healthy later sibling's id. A
stored reference to a malformed id does break here - but the behaviour this
replaces DELETED the choice, which broke the same reference and lost the choice
with it.

The seam is memoized on the raw store value, which is load-bearing rather than an
optimization: this subscription fires on every settingsStore write, including the
AI provider auto-sync a few seconds after launch, and every by-id write in the
view resolves its target BEFORE an await - so re-minting inside that window turns
`oldChoice.id === newChoice.id` into a no-op and silently discards the user's
builder edits.

Two more holes closed on the way:
- ChoiceList's filter now de-duplicates ids, like CommandList's already did. A
  duplicate arriving after load reached the keyed {#each} and threw
  `each_key_duplicate`, and a POST-mount throw escapes mountComponent's try.
- An Array entry in a choices list is read as a NESTED LIST and spliced in, the
  same reading `macroCommandsValueOf` gives an array-valued `macro`.
  `isChoiceLike([])` is true, so the alternative was spreading it into one
  nameless, typeless row. Mirrored in `normalizeCommandList` so the twins stay
  byte-symmetric.

Closes #1608
…d secrets

`regenerateIds` re-ided the macro and its TOP-LEVEL commands only, so duplicating
a macro left every id below that identical between the original and the copy: a
Conditional's thenCommands/elseCommands, a NestedChoice's inner choice, and that
choice's own macro if it had one.

Nothing crashed, because it is not a within-list collision. But
`buildUserScriptSecretId` keys a stored user-script secret on `command.id`, and
`migrateUserScriptSecretSettings` re-adopts an existing secret by that id for any
option declaring a stable `id` - so a duplicated macro's NESTED UserScript
command adopted the ORIGINAL's secret slot, and setting the API key on the copy
set it on the original. The nested `IChoice` kept its id too, colliding with the
original's in every by-id walk over commands.

`regenerateIds` now recurses the whole macro: branch commands, a nested choice
(and its macro, or its children if it is a folder). It stays total over whatever
data.json holds - a non-array command list is left exactly as found, a hole is
stepped over - and resolves the list through `macroCommandsValueOf`, so an
array-valued macro is re-ided as the command list it is instead of having a
non-index `id` written onto it that JSON.stringify would drop.

That last shape turned out to hide the reported symptom from the fix. The secret
sanitizers reached `choice.macro.commands` literally behind an `isRecord` gate,
and `isRecord` accepts an array - so for `macro: [ ... ]` they walked nothing,
the copy kept the original's literal `secretRef`, and editing the copy's key
overwrote the original's SecretStorage slot. `stripUserScriptSecretRefsFromChoice`,
`clearSecretsFromChoice` and `collectUserScriptPathsFromChoice` now read through
`macroCommandsValueOf` like the macro builder does.

The regression test asserts the SYMPTOM - the copy carries no secret reference
belonging to the original - not just that the ids differ. An ids-only assertion
passes while the reported bug still reproduces through the path that never re-ids.

Closes #1609
Delete is the one irreversible action in the settings list, and the two container
types were treated asymmetrically. A folder whose `choices` value could not be
read got a specific warning; a macro got only

    Deleting this choice will also delete its macro commands.

which is true and says nothing about the case that matters - that `macro.commands`
holds something QuickAdd could not read (`{"0": {...}}`, a string, a number).
Since #1593 the macro builder tells the user about exactly that state and refuses
to overwrite it, and then the one screen that was about to destroy it for good
stayed quiet.

The confirmation now reads the SAME predicate through the SAME accessor the
builder resolves its list with, so the screen that reports the state and the
screen that destroys it cannot disagree. `macroCommandsValueOf` rather than
`macro?.commands` is what makes the unreadable-`macro`-object case resolve here
exactly as it does there - and it is also why an array-valued `macro` correctly
keeps the ordinary line: the array IS the command list, the builder can read and
edit it, so there is nothing to warn about.

Closes #1612
…he macro builder

`MacroBuilder.configureConditionalBranch` opens the branch editor OVER the still-
open macro builder. Both mount a CommandList, and both registered their drop zone
with the same svelte-dnd-action type. The library groups zones by type and
hit-tests every zone in the group GEOMETRICALLY - a modal backdrop shields
nothing - so the builder's list underneath was a live drop target for a drag
inside the branch modal, and the two lists overlap almost exactly.

Confirmed live: with the Then-branch editor open over a macro, dragging its one
command down past the branch list's own bottom edge - still inside the modal -
moved it into the macro builder underneath. Both editors then rendered a list
that disagreed with the stored macro: the builder showed a command the macro did
not have, and the conditional's summary read "Then: 0" for a branch that still
had one.

Each CommandList now gets its own zone type, so no two command lists are ever in
the same group. QuickAdd offers no cross-list command drag - unlike a folder, a
command has no "move into" gesture - so a group of one is the honest model, and
leaving the zone is then an ordinary "outside any zone" drag: the command springs
back.

`dropFromOthersDisabled: true` was the first fix and is worse, which is only
visible live. The target does refuse, but the library then runs its "left for a
zone that refuses" path, which re-dispatches the origin's items twice - once with
its shadow placeholder and once without - and QuickAdd's stripShadow (#1244/#883)
drops the placeholder the library goes on to measure. The drag ended in an
uncaught `Cannot read properties of undefined (reading 'getBoundingClientRect')`
with the command gone from the source list: a worse outcome than the bug.

Verified after the fix: the same drag leaves both lists and the stored macro
untouched, an ordinary reorder within one list still works, and dev:errors is
clean for both.

Closes #1613
…ds total

Follow-ups from an adversarial review of the six fixes in this branch. Each of
these was a real defect in them, found by probe rather than by reading.

**The id repair leaked a dead palette entry per settings open.** The memo lived
in ChoiceView, and the settings tab DESTROYS and re-mounts the view every time it
is opened - so an unrepaired `{"id": 12, "command": true}` minted a fresh uuid and
registered another command on every open, with nothing unregistering. It now
lives in a module-level WeakMap keyed on the raw array (`seedChoiceTree`), so the
cache outlives the component and dies with the array. Verified live: three opens,
one stable id, two palette entries (the original id from data.json plus the one
repair) rather than four.

Keying that map on the choice's own previous id would NOT work: `undefined`
collides across every id-less entry, so two siblings would be handed the same
uuid - `each_key_duplicate` again.

The registration loop is also wrapped now. It runs during component setup, so an
uncaught throw escaped mountComponent and would have swapped the whole choice
list for the error card - one failure must cost one command, the same argument
main.ts makes around `addCommandForChoice`.

**A hole in `settings.macros` still crashed two migrations.** `[null, {...}]` IS
an array, so it reads as readable and reaches the loop, where `macro.commands`
throws. Both now read through `commandListOf(macroCommandsValueOf(macro))`, which
steps over the hole AND gives an array-valued legacy macro the same reading every
other consumer gives it.

**The traversal and the editor seam disagreed about a nested array.**
`typeof [] === "object"`, so `walkChoice` handed the array to the visitor and
descended nothing, reporting READABLE - while `normalizeChoiceList` splices such
an entry's members into the tree. A migration could be flagged complete over a
choice the settings tab then made real. The walk now splices too.

**Two tests asserted nothing.** `expect(c.fileOpening ?? c.type).toBeTruthy()`
falls back to the string "Template", so all three migration rows passed with
their visitor bodies deleted; each row now carries its own effect assertion, and
gutting any visitor fails its row. And #1613 shipped with no regression coverage
at all - reverting the zone type left the full suite green. `CommandList.zoneType.test.ts`
wraps svelte-dnd-action's action to record the type each list registers, which is
the only observable seam (the library writes no attribute); it fails with
`expected [ 'command', 'command' ] to not include 'command'` on the reverted tree.

**`commandZoneId.ts` is gone.** Svelte's own `$props.id()` is a runtime-wide uid
counter and is already used in-tree by LabeledField - a module, an export and a
counter for that was 35 lines of nothing.

Also: the direct #1609 symptom is now asserted through `buildUserScriptSecretId`
(the copy's nested script resolves to a different secret slot), the comment
claiming the two legacy-macro migrations' guards match their traversals is
corrected to say they are knowingly narrower and why widening is inert, and the
`normalizeCommandList` array branch gets the coverage its twin already had.
@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 7557ccd7-7759-492d-93f5-ba61c088bdfb

📥 Commits

Reviewing files that changed from the base of the PR and between 48ed5a1 and 08c7201.

📒 Files selected for processing (7)
  • src/migrations/helpers/choice-traversal.ts
  • src/migrations/migrationReadability.test.ts
  • src/migrations/removeMacroIndirection.ts
  • src/services/duplicateChoice.nestedIds.test.ts
  • src/utils/choiceUtils.ts
  • src/utils/macroUtils.ts
  • src/utils/userScriptSecrets.ts
🚧 Files skipped from review as they are similar to previous changes (4)
  • src/migrations/removeMacroIndirection.ts
  • src/migrations/migrationReadability.test.ts
  • src/services/duplicateChoice.nestedIds.test.ts
  • src/utils/choiceUtils.ts

📝 Walkthrough

Walkthrough

The changes normalize malformed choice and macro trees, preserve repaired identifiers, harden migrations against unreadable settings, update macro duplication and secret traversal, improve deletion warnings, and isolate command-list drag-and-drop zones.

Changes

Choice rendering and normalization

Layer / File(s) Summary
Choice normalization contracts and behavior
src/utils/choiceUtils.ts, src/utils/normalizeChoiceList.test.ts, src/utils/choiceUtils.test.ts, src/utils/malformedChoices.fixture.ts
Unreadable-value detection and choice normalization distinguish empty values, flatten nested lists, remove invalid entries, and repair duplicate or unusable IDs.
Choice view seeding and stable repaired IDs
src/gui/choiceList/ChoiceView.svelte, src/gui/choiceList/ChoiceList.svelte, src/gui/choiceList/seedChoiceTree.ts, src/gui/choiceList/ChoiceView.malformed.test.ts
Choice views memoize normalization, retain repaired entries, enable repaired commands, and preserve repaired IDs through updates, reorders, and remounts.

Malformed macro data handling

Layer / File(s) Summary
Macro normalization and recursive duplication
src/utils/macroUtils.ts, src/services/duplicateChoice.nestedIds.test.ts, src/utils/macroUtils.test.ts
Macro utilities flatten nested command lists, safely access root macros, recursively regenerate nested IDs, and preserve shared-reference behavior during duplication.
Unreadability-aware migration traversal
src/migrations/helpers/choice-traversal.ts, src/migrations/*.ts
Traversal reports unreadable choice and command subtrees; affected migrations return pending results and preserve malformed settings instead of overwriting them.
Malformed-data regression coverage and user messaging
src/migrations/migrationReadability.test.ts, src/services/*, src/utils/userScriptSecrets.ts, src/utils/*test.ts
Tests cover malformed settings and macro data, while deletion warnings and secret operations use shared macro command accessors.

Drag-and-drop zone isolation

Layer / File(s) Summary
Per-instance command DnD zones
src/gui/MacroGUIs/CommandList.svelte, src/gui/MacroGUIs/CommandList.zoneType.test.ts, src/gui/shared/dndReorder.ts, src/gui/shared/dndReorder.test.ts
Each command list registers a distinct command:-prefixed DnD type, with coverage for zone uniqueness and base option values.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related issues

Possibly related PRs

Poem

A bunny finds each key in place,
And mends the paths through tangled space.
Bad commands wait, migrations pause,
Secrets hop safely past old flaws.
DnD zones now keep their own lane—
Hop, hop, hooray for cleaner terrain!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 41.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title matches the main change: fixing malformed data.json choice handling and related data-loss behavior.
Linked Issues check ✅ Passed The PR addresses all six linked issues with matching code paths and regression tests.
Out of Scope Changes check ✅ Passed The supporting normalization, traversal, and test changes are tied to the linked fixes and do not look unrelated.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch chhoumann/1608-macro-data-robustness

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Jul 27, 2026

Copy link
Copy Markdown

Deploying quickadd with  Cloudflare Pages  Cloudflare Pages

Latest commit: 08c7201
Status: ✅  Deploy successful!
Preview URL: https://bc63a776.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1608-macro-data-ro.quickadd.pages.dev

View logs

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 48ed5a1501

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/utils/macroUtils.ts
Comment thread src/utils/choiceUtils.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🧹 Nitpick comments (4)
src/gui/MacroGUIs/CommandList.zoneType.test.ts (1)

36-54: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Keep this unit test independent of the real Obsidian runtime.

new App() is unnecessary for this DnD-only regression test and defeats the repository’s testability boundary. Import App as a type and pass a minimal stub, or inject an app interface.

Suggested adjustment
-import { App } from "obsidian";
+import type { App } from "obsidian";

-			app: new App() as never,
+			app: {} as App,

As per coding guidelines, TypeScript code should inject Obsidian dependencies behind interfaces so production logic can be tested without loading real Obsidian modules.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/gui/MacroGUIs/CommandList.zoneType.test.ts` around lines 36 - 54, Update
the renderList test helper to avoid constructing the real Obsidian runtime:
change App to a type-only import and provide a minimal stub matching the app
dependency expected by CommandList. Keep the existing DnD regression-test
behavior and other injected props unchanged.

Source: Coding guidelines

src/utils/normalizeChoiceList.test.ts (1)

147-159: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Guard the fixture-driven loop against a vacuous pass.

If MALFORMED_CHILDREN_SHAPES ever loses its lossy shapes (or the flag is renamed), this test passes with zero iterations and silently stops covering #1566/#1608 preservation. A length assertion pins it.

♻️ Assert the loop actually ran
 	it("leaves an unreadable folder's children value exactly as found", () => {
-		for (const shape of MALFORMED_CHILDREN_SHAPES.filter((s) => s.lossy)) {
+		const lossy = MALFORMED_CHILDREN_SHAPES.filter((s) => s.lossy);
+		expect(lossy.length).toBeGreaterThan(0);
+		for (const shape of lossy) {
 			const broken = malformedFolder("Broken", "broken", shape.value);
 			const { choices } = normalizeChoiceList([broken]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/utils/normalizeChoiceList.test.ts` around lines 147 - 159, Ensure the
test case around MALFORMED_CHILDREN_SHAPES asserts that the filtered lossy-shape
collection is non-empty before iterating, so it cannot pass vacuously if the
fixture data or flag changes. Keep the existing per-shape preservation
assertions unchanged.
src/migrations/helpers/choice-traversal.ts (1)

21-27: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Doc says the callback receives the container value, but onUnreadable takes no parameters. Either drop "Called with" or pass the offending value through so callers can log it.

📝 Wording tweak
-	/**
-	 * Called with every container value this walk had to step over because it
-	 * could not read it. What makes `settingsTreeHasUnreadableData` trustworthy:
+	/**
+	 * Called once for every container this walk had to step over because it
+	 * could not read it. What makes `settingsTreeHasUnreadableData` trustworthy:
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/migrations/helpers/choice-traversal.ts` around lines 21 - 27, Align the
onUnreadable documentation with its declared callback signature in the choice
traversal options: either remove the claim that the callback receives a
container value, or update onUnreadable and its invocations to pass the
offending value through. Preserve the existing unreadable-subtree traversal
behavior.
src/migrations/migrationReadability.test.ts (1)

320-320: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert the resolved value instead of not.toThrow(). This migrate(...).resolves branch tests normal completion via .resolves; appending .not.toThrow() applies a toThrow matcher to a non-function resolved value, while the rejection path should use .resolves on a function wrapper if throwing during execution is the intent.

♻️ Suggested change
-		await expect(migration.migrate(plugin)).resolves.not.toThrow();
+		await expect(migration.migrate(plugin)).resolves.toBeUndefined();

Check the expected resolved value for this row before tightening this assertion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/migrations/migrationReadability.test.ts` at line 320, Update the
assertion for migration.migrate(plugin) to verify its expected resolved value
rather than chaining .resolves.not.toThrow(). Check the expected value for this
test row and assert that value directly; preserve the separate
rejection/throwing-path assertion pattern where applicable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/migrations/migrationReadability.test.ts`:
- Around line 107-123: Await backfillFileOpeningDefaults.migrate(plugin) in the
test before inspecting plugin.settings, so migration failures and asynchronous
mutations are observed. Rename the inner nested variable to avoid shadowing the
module-level factory while preserving the existing assertions.

In `@src/migrations/removeMacroIndirection.ts`:
- Around line 96-98: Strengthen the guard in the macro-entry iteration around
the existing `macro` check to continue unless the value is an object with a
string `id`. This prevents entries lacking a valid identifier from reaching
`choicesByMacroId.get` or `new MacroChoice`; preserve the existing handling for
valid macro objects and primitive or null holes.

In `@src/services/choiceService.test.ts`:
- Around line 559-578: Remove the duplicate “an object macro with no commands
key” case and its associated comment from the parameterized test, since both it
and “no commands key” pass undefined and construct the same macroValue. Keep the
distinct readable-list, null, and array-valued cases unchanged.

In `@src/utils/macroUtils.ts`:
- Around line 259-291: Update regenerateCommandIds to normalize array-shaped
entries with normalizeCommandList before iterating and regenerating IDs, so
nested commands are flattened and their thenCommands, elseCommands, and choice
branches are traversed. Avoid assigning IDs directly to array entries; preserve
visited tracking and UUID regeneration for actual command objects.

---

Nitpick comments:
In `@src/gui/MacroGUIs/CommandList.zoneType.test.ts`:
- Around line 36-54: Update the renderList test helper to avoid constructing the
real Obsidian runtime: change App to a type-only import and provide a minimal
stub matching the app dependency expected by CommandList. Keep the existing DnD
regression-test behavior and other injected props unchanged.

In `@src/migrations/helpers/choice-traversal.ts`:
- Around line 21-27: Align the onUnreadable documentation with its declared
callback signature in the choice traversal options: either remove the claim that
the callback receives a container value, or update onUnreadable and its
invocations to pass the offending value through. Preserve the existing
unreadable-subtree traversal behavior.

In `@src/migrations/migrationReadability.test.ts`:
- Line 320: Update the assertion for migration.migrate(plugin) to verify its
expected resolved value rather than chaining .resolves.not.toThrow(). Check the
expected value for this test row and assert that value directly; preserve the
separate rejection/throwing-path assertion pattern where applicable.

In `@src/utils/normalizeChoiceList.test.ts`:
- Around line 147-159: Ensure the test case around MALFORMED_CHILDREN_SHAPES
asserts that the filtered lossy-shape collection is non-empty before iterating,
so it cannot pass vacuously if the fixture data or flag changes. Keep the
existing per-shape preservation assertions unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 8e3c9cb0-1162-41e7-9f50-f3644fbd1cdd

📥 Commits

Reviewing files that changed from the base of the PR and between 462d0c9 and 48ed5a1.

📒 Files selected for processing (28)
  • src/gui/MacroGUIs/CommandList.svelte
  • src/gui/MacroGUIs/CommandList.zoneType.test.ts
  • src/gui/choiceList/ChoiceList.svelte
  • src/gui/choiceList/ChoiceView.malformed.test.ts
  • src/gui/choiceList/ChoiceView.svelte
  • src/gui/choiceList/seedChoiceTree.ts
  • src/gui/shared/dndReorder.test.ts
  • src/gui/shared/dndReorder.ts
  • src/migrations/backfillFileOpeningDefaults.test.ts
  • src/migrations/backfillFileOpeningDefaults.ts
  • src/migrations/consolidateFileExistsBehavior.ts
  • src/migrations/helpers/choice-traversal.ts
  • src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts
  • src/migrations/migrateFileOpeningSettings.ts
  • src/migrations/migrationReadability.test.ts
  • src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts
  • src/migrations/removeMacroIndirection.ts
  • src/services/choiceService.test.ts
  • src/services/choiceService.ts
  • src/services/duplicateChoice.nestedIds.test.ts
  • src/utils/choiceUtils.test.ts
  • src/utils/choiceUtils.ts
  • src/utils/macroUtils.test.ts
  • src/utils/macroUtils.ts
  • src/utils/malformedChoices.fixture.ts
  • src/utils/normalizeChoiceList.test.ts
  • src/utils/unreadableValuePredicates.test.ts
  • src/utils/userScriptSecrets.ts

Comment thread src/migrations/migrationReadability.test.ts
Comment thread src/migrations/removeMacroIndirection.ts
Comment thread src/services/choiceService.test.ts
Comment thread src/utils/macroUtils.ts
…ut it

Review follow-ups on this branch. `normalizeCommandList`/`normalizeChoiceList`
read a nested ARRAY entry as a LIST and splice its members in - `isChoiceLike([])`
is true, so the alternative is spreading it into one nameless row - but nothing
else in the tree agreed, and the disagreement was reachable in two ways.

Duplication treated a nested array as a COMMAND: `regenerateCommandIds` wrote an
`id` onto it that JSON.stringify drops, and the secret walkers stepped past it -
so every id and every literal `secretRef` inside it stayed shared with the
original, which is #1609's symptom surviving #1609's fix. All three now recurse.

The migration guards said "readable" for a nested array in `choices`, and the
narrow migrations cannot descend one: `flattenChoices` pushes the array as if it
WERE a choice, and the increment / mutual-exclusion recursions step straight past
it. So `removeMacroIndirection` could classify a macro referenced from inside one
as orphaned, duplicate it at the root and delete `settings.macros`, permanently.

Both guards now REPORT a nested array rather than descending it - deliberately,
and against the first fix here, which made the shared walk descend it. That walk
can; the traversals the guard answers for cannot, and a guard that says "I saw
everything" on behalf of a walker that did not is precisely the failure #1610 is
about. The cost of the conservative answer is one launch: the user opens the
settings tab, the seam splices the entries into real choices, and the next launch
reads clean.

Also from review: an entry in `settings.macros` with no usable `id` is no longer
rehomed - the lookup missed, the orphan branch ran, and a nameless MacroChoice
was pushed into the choice tree and made permanent when `macros` was deleted. And
two test fixes - a `void`ed migration that only passed because the mutation
happened before the first await, and two `it.each` rows that were byte-identical.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment