fix: a malformed macro command list no longer makes the macro unrepairable - #1616
Conversation
`IMacro.commands` is declared `ICommand[]` and nothing enforces it: data.json is hand-edited, imported, half-written and sync-merged. Adds the same accessor split `Multi.choices` got in #1583 - `commandListOf` (total READ view), `hasCommandList` (WRITE guard), `isUnreadableCommandList` (the line between degrading quietly and telling the user) - plus `normalizeCommandList`, the editor-seam repair that keeps a duplicate-id or id-less command under a fresh uuid instead of hiding it. Also makes `regenerateIds` total: it is a WRITE path reached from duplicateChoice, so a non-array `commands` is now left exactly as found and a hole in the list is stepped over rather than dereferenced. Refs #1593
…cro editor Before this, every malformed `macro.commands` landed in the same place: the error card #1584 introduced, with no controls - a macro repairable only by hand-editing data.json. Verified live against seven shapes; four of them were dead ends that did not need to be. The editor now has three states, decided by whether the value could still be CARRYING commands: - a readable array -> fully editable. A duplicate-id or id-less command is kept under a fresh uuid (normalizeCommandList) rather than dropped, so the user can see and delete it; a `null` hole, which carries nothing, is stepped over. - a value that carries nothing (null, {}, absent) -> an empty but fully usable editor. `macro: null` previously made "Configure" do nothing at all: display() runs from MacroBuilder's constructor, so the throw took the modal with it. - a value we cannot read that could be carrying commands ({"0":...}, "str", 7) -> a card that says so, with every writing affordance withheld, so the `[]` we read it as can never reach disk. render() reports this to its host, because ConditionalBranchEditorModal's Save button lives outside the editor and would otherwise overwrite an unreadable branch in one click. The repair is never persisted by opening a macro - only by the user's first ordinary edit - so open-and-close leaves data.json byte-identical. Refs #1593
…ing or lying Two of the malformed shapes were broken outside the UI too, both because `if (!this.macro.commands)` is a truthiness test: an array-turned-object reached `for..of` and surfaced a bare "i is not iterable" to the user, and a string reached it INTACT - strings are iterable - so the macro reported success having walked its own characters and run nothing. A conditional's branch had the same hole one level down, skipped as silently as a branch the user left empty. Both now say what happened, name the macro, and point at data.json. Also routes the remaining command-list walkers through the accessor, guarding them at their own entry point so the recursion into then/else branches is covered too: packageTraversal, packageImportService, packagePreview, collectChoiceRequirements, SingleMacroEngine. Deliberately NOT swept: removeMacroIndirection's `macro.commands || []`. That is truthiness, so it already passes an unreadable value through VERBATIM; substituting the accessor would replace it with [] at the one migration that then deletes settings.macros and is flagged complete forever. Refs #1593
… lists The #1566 fixture had no Macro node at all, so two rows that already swept macro-reaching walkers - duplicateChoice (regenerateIds) and collectChoiceClosure (packageTraversal) - were dead coverage for them. Grows the one shared fixture rather than forking a second one that would drift: every malformed `commands` shape, a missing `macro` object, an unkeyable-but-readable array (duplicate id / no id / hole), and corruption inside a conditional's branches, all repeated at every depth. malformedSnapshot now pins command values too, and pins ARITY for a readable list: a walker may re-key an entry (that is the #1593 repair) but must never lose one. The new buildPackagePreview row found a pre-existing hole of the same class in collectChoice, which walked a packaged folder's children and dereferenced a null hole. Fixed alongside. Refs #1593
…tself
Five defects, three of them regressions this branch introduced. All reproduced
before fixing, all now covered by tests that fail without the fix.
- `macro: []` / `macro: [cmd]`: `isCommandLike` accepts arrays (typeof [] is
"object"), so the builder opened a fully editable editor and then wrote
`macro.commands = [...]` onto an Array - a non-index property that
JSON.stringify drops. The user added commands, saw them, and every save
silently discarded them. New `isMacroObject` rejects arrays; the array's
entries now render as the commands they probably are and the first edit
materializes a real macro object around them.
- `commands: []` is the HEALTHY default (QuickAddMacro's constructor), and the
new length check turned it into a 15-second red notice on every run - and on
every launch for an unpopulated run-on-startup macro. Only a MISSING list
says anything now, matching the base behaviour and the sibling branch guard.
- `[ok, null, ok]` still threw on the `{{MACRO:Name::member}}` path:
commandListOf converts the non-array shapes, but a hole INSIDE a real array
sails through .map/.filter. This was the one asymmetric entrypoint - the same
macro ran fine without member access.
- A `null` hole in a PACKAGED folder aborted the whole import. Guarding the
preview walker earlier in this branch is what made it reachable: the package
now previews cleanly, so the user gets to click Import and fail there. Fixed
on both the import and export sides.
- A conditional's branch rendered "Then: 10" for a branch saved as "not a
list" (its character count) and "Then: 0" for an array-turned-object.
Refs #1593
📝 WalkthroughWalkthroughThis change adds shared guards and normalization for malformed macro command data, applies them across execution and package traversal, and updates macro editors to filter salvageable entries or show unreadable data without editing controls. Tests cover malformed lists, conditional branches, nested fixtures, and UI behavior. ChangesMalformed macro resilience
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Possibly related PRs
Suggested labels: Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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. Comment |
Deploying quickadd with
|
| Latest commit: |
3c50e32
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://038499e5.quickadd.pages.dev |
| Branch Preview URL: | https://chhoumann-1593-macro-data-gu.quickadd.pages.dev |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: b10a8ad0a9
ℹ️ 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".
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/services/packagePreview.ts (1)
462-473: 🔒 Security & Privacy | 🟠 Major | ⚡ Quick winHandle array-valued
macrovalues in the package security scan.
parseQuickAddPackage’s structural validation accepts untrusted choice/macro shapes and does not rejectchoice.macroas an array. When preview walkschoice.macro?.commands, that array-macro case becomesundefined, while the macro editor treats the array entries as the commands. Scanned commands can therefore be hidden from the pre-install capability/dependency summary, including user scripts and conditional scripts inside that array. AligncollectCommandsand the dependency collector with the editor read path: whenchoice.macrois an array, collect the entries as commands instead of dereferencing.commands.🤖 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/services/packagePreview.ts` around lines 462 - 473, The package security scan must handle array-valued choice.macro entries consistently with the macro editor. Update the collectCommands call in the isMacroChoice path and the corresponding dependency collector to use the macro array itself as commands when choice.macro is an array, while preserving the existing .commands handling for object-valued macros.
🧹 Nitpick comments (1)
src/services/packageImportService.ts (1)
907-912: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winPrefer
isMacroObjectoverisCommandLikefor the macro guard.
isCommandLikeaccepts arrays, andmacroUtilsdocuments exactly that hazard: setting a non-index property (macro.id) on an array is dropped byJSON.stringify, so the regenerated id silently never persists.isMacroObjectis the predicate written for this case, and it also reads better than a "command" guard applied to a macro.♻️ Proposed change
- if (isDuplicated && isCommandLike(macroChoice.macro)) { + if (isDuplicated && isMacroObject(macroChoice.macro)) { macroChoice.macro.id = uuidv4(); }Also update the import on Line 20 to include
isMacroObject.🤖 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/services/packageImportService.ts` around lines 907 - 912, Replace the isCommandLike guard in the duplicated macro handling with isMacroObject, and update the import to include isMacroObject. Keep the existing UUID assignment unchanged so IDs are regenerated only for valid macro objects.
🤖 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/utils/malformedChoices.entrypoints.test.ts`:
- Around line 180-192: Update the packageOf() test helper to populate the
QuickAddPackage rootChoiceIds field instead of entryChoiceIds, using the
existing filtered choice IDs. Remove the unknown type assertion if the object
now satisfies QuickAddPackage, while preserving the remaining fixture fields.
---
Outside diff comments:
In `@src/services/packagePreview.ts`:
- Around line 462-473: The package security scan must handle array-valued
choice.macro entries consistently with the macro editor. Update the
collectCommands call in the isMacroChoice path and the corresponding dependency
collector to use the macro array itself as commands when choice.macro is an
array, while preserving the existing .commands handling for object-valued
macros.
---
Nitpick comments:
In `@src/services/packageImportService.ts`:
- Around line 907-912: Replace the isCommandLike guard in the duplicated macro
handling with isMacroObject, and update the import to include isMacroObject.
Keep the existing UUID assignment unchanged so IDs are regenerated only for
valid macro objects.
🪄 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: 67d9a33a-8861-4ebc-822f-8c1f7d6fbb79
📒 Files selected for processing (21)
src/engine/MacroChoiceEngine.malformed.test.tssrc/engine/MacroChoiceEngine.tssrc/engine/SingleMacroEngine.member-access.test.tssrc/engine/SingleMacroEngine.tssrc/gui/MacroGUIs/CommandList.sveltesrc/gui/MacroGUIs/CommandSequenceEditor.tssrc/gui/MacroGUIs/CommandSequenceEditor.unrenderable.test.tssrc/gui/MacroGUIs/Components/ConditionalCommand.sveltesrc/gui/MacroGUIs/ConditionalBranchEditorModal.tssrc/gui/MacroGUIs/MacroBuilder.malformed.test.tssrc/gui/MacroGUIs/MacroBuilder.tssrc/gui/svelte/DataUnreadable.sveltesrc/preflight/collectChoiceRequirements.tssrc/services/packageExportService.tssrc/services/packageImportService.tssrc/services/packagePreview.tssrc/utils/macroUtils.test.tssrc/utils/macroUtils.tssrc/utils/malformedChoices.entrypoints.test.tssrc/utils/malformedChoices.fixture.tssrc/utils/packageTraversal.ts
…uccess Review follow-up on #1616. - run() and executeConditional now THROW for a command list they could not read, rather than logging and returning. Returning let `quickadd:run` answer ok:true for a macro that ran nothing, so automation carried on as if it had worked - and in the conditional case the outer loop went on to run every command AFTER the branch, including file writes that were only ever meant to follow a branch that never ran. - packageImportService used the looser isCommandLike for the `macro` object, so `macro.id = uuidv4()` on an array-valued macro was a silent no-op (JSON drops non-index properties) and the duplicate kept the original's id. - remapCommands and applyOverridesToCommands read `macro?.commands`, which is undefined for an array-valued macro - so the choice references and secret refs its entries hold were never remapped, and a duplicated choice could still invoke the pre-existing local one. Both now go through the new shared macroCommandsValueOf, which is also what MacroBuilder's recovery path reads, so the two cannot disagree about where a macro's commands live. Refs #1593
…st it `packageOf` set `entryChoiceIds` where QuickAddPackage declares `rootChoiceIds`, and an `as unknown as` hid the mismatch. It happens not to matter today because walkPackage derives its entry ids from `pkg.choices`, but a fixture that does not actually satisfy the shape the walker is handed in production is exactly the drift this sweep exists to catch. Structurally typed now, with no cast. Refs #1593
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 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/services/packageImportService.ts`:
- Around line 912-925: Add package-import regression tests covering the recovery
paths around isMacroObject and macroCommandsValueOf: verify direct-array macros
remap choice and secret references and apply asset-path overrides, and verify
Multi children that are null or non-choice are handled safely. Place the
coverage in the existing package-import test suite and include cases
corresponding to the related logic near the cited symbols.
🪄 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: 09e2f13b-4837-4d74-912d-24b25ca1ad3e
📒 Files selected for processing (6)
src/engine/MacroChoiceEngine.malformed.test.tssrc/engine/MacroChoiceEngine.tssrc/gui/MacroGUIs/MacroBuilder.tssrc/services/packageImportService.tssrc/utils/macroUtils.tssrc/utils/malformedChoices.entrypoints.test.ts
🚧 Files skipped from review as they are similar to previous changes (4)
- src/gui/MacroGUIs/MacroBuilder.ts
- src/engine/MacroChoiceEngine.ts
- src/utils/malformedChoices.entrypoints.test.ts
- src/utils/macroUtils.ts
…eal import Drives applyPackageImport end-to-end over every malformed `macro` shape, and pins the two behaviours the last round changed: - an array-valued macro has its choice references remapped, so a duplicated choice's reference follows the imported copy instead of resolving to the pre-existing local choice - a `null` hole in a packaged folder's children no longer aborts the whole import Both fail without the fix (verified by reverting each guard in turn). Refs #1593
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/services/packageImportService.malformed.test.ts (1)
209-227: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winAssertion can't actually detect the regression it names.
Per the upstream comment in
packageImportService.ts(context snippet 2), the guarded bug ismacro.id = uuidv4()being applied to an Array — a non-index property thatJSON.stringifysilently drops. But this test only asserts againstJSON.parse(JSON.stringify(result.updatedChoices))(Line 217), and that serialization step erases the very property the bug would set — whether the buggyisCommandLikecheck or the fixedisMacroObjectcheck ran, the JSON output is identical. As written, this test would pass even if the fix were reverted.Consider asserting on the pre-serialization object instead (or in addition), e.g. checking that the array has no own
idproperty, to actually distinguish the fixed vs. buggy code path.Based on path instructions (`**/*.{test,spec}.{ts,tsx}`: "Add regression coverage for bug fixes."), the test's name promises coverage this assertion doesn't actually provide.♻️ Suggested additional assertion
const macro = macroChoice("m1", [choiceCommand("cmd-1", "nope")]); const result = await importPackaged([macro], "duplicate", [ macroChoice("m1", [choiceCommand("cmd-1", "nope")]), ]); + // Check the raw (pre-serialization) object, since JSON.stringify would + // silently mask a stray non-index `.id` property on an array either way. + const rawMacros = result.updatedChoices.filter( + (c) => c.type === "Macro", + ) as unknown as { macro: unknown[] }[]; + for (const m of rawMacros) { + expect(Object.prototype.hasOwnProperty.call(m.macro, "id")).toBe(false); + } + // The array survives the JSON round-trip whole; nothing was written onto it // as a non-index property (which JSON.stringify would have dropped). const persisted = JSON.parse(JSON.stringify(result.updatedChoices));🤖 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/services/packageImportService.malformed.test.ts` around lines 209 - 227, Update the test “does not silently no-op the macro id when duplicating an array-valued macro” to inspect the pre-serialization objects in result.updatedChoices, asserting each array-valued macro has no own id property. Keep the existing JSON round-trip assertions if useful, but add a direct assertion that distinguishes the fixed isMacroObject behavior from the regression.Source: Path instructions
🤖 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.
Nitpick comments:
In `@src/services/packageImportService.malformed.test.ts`:
- Around line 209-227: Update the test “does not silently no-op the macro id
when duplicating an array-valued macro” to inspect the pre-serialization objects
in result.updatedChoices, asserting each array-valued macro has no own id
property. Keep the existing JSON round-trip assertions if useful, but add a
direct assertion that distinguishes the fixed isMacroObject behavior from the
regression.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: d9dab145-12d4-4494-9419-cc0dd25695bc
📒 Files selected for processing (1)
src/services/packageImportService.malformed.test.ts
Closes #1593
The problem
IMacro.commandsis declaredICommand[]and nothing enforces it. It comes fromdata.json, which is hand-edited, imported as a package, half-written, or merged by Obsidian Sync's whole-file last-write-wins. Every reader dereferenced it raw.The issue title says "no read-accessor guard". That is the mechanism. The user-facing problem is that a macro whose command list has one bad byte could not be repaired from the UI at all - and two of the shapes were broken outside the UI too.
Reproduction
Nine macro shapes seeded into
data.jsonin this worktree's isolated vault (Obsidian 1.13.0), each opened through the real settings → "Configure" path and each run viaquickadd:run. Verified onmaster@f9f158d0and again on this branch.macro/macro.commandscommands: nullcommands: [{id:"dup"},{id:"dup"}]each_key_duplicate) - dead endcommands: [ok, null, ok]null.id) - dead endcommands: [](healthy default)commands: {"0": {…}}i is not iterablecommands: "not a list"ok:truehaving done nothing (strings are iterable)macro: nullmacro: [{…}]The
macro: nullrow is the worst one and was not in the issue:display()runs fromMacroBuilder's constructor, beforeopen(), so the throw took the modal with it.Before / after
A macro with two commands sharing an id - a real, working macro that one bad byte made uneditable:
A
commandsvalue we genuinely cannot read. It stays a dead end, but an honest one that names what happened, promises not to overwrite it, and says where it lives:The other two
commands: [ok, null, ok]andcommands: null:Design
Follows the accessor split #1583 gave
Multi.choices, insrc/utils/macroUtils.ts:commandListOf(value)- total READ view. Takesunknown, likerootChoicesOf, becausechoice.macrois untrusted too.hasCommandList(value)- WRITE guard, so nothing rebuilds a value it could not read.isUnreadableCommandList(value)- the line between degrading quietly and telling the user:null/{}/""carry nothing;{"0":…}/"str"/7might.isMacroObject/isUnreadableMacro- the same questions one level up.normalizeCommandList(value)- the editor-seam repair.The editor has three states, decided by whether the value could still be carrying commands:
nullhole, which carries nothing, is stepped over.[]we read it as can never reach disk.render()reports which state it is in, becauseConditionalBranchEditorModal's Save button lives outside the editor and would otherwise overwrite an unreadable branch in one click. It shows a single "Close" instead.Tradeoffs
Re-key, don't drop. The issue proposed a render-time filter mirroring
ChoiceList.svelte:62. I did not ship that for duplicate ids, because the editor persists the list it rendered: the first reorder would delete a real command fromdata.jsonwith no prompt and no undo. Re-keying loses nothing. (The filter survives inCommandList.svelteas a post-mount backstop, where it can only ever drop a hole -each_key_duplicateon an update escapesmountComponent's try entirely.)Repair at the editor seam, not at load. A
dedupeCommandsByIdinloadSettingswas considered and rejected:loadSettingsruns before the migrations and never saves, so it would mint fresh uuids on every launch until an unrelated save landed. At the seam it is idempotent per session, covers both hosts that show a command list, and rides along with the user's first ordinary edit - so opening and closing a malformed macro leavesdata.jsonbyte-identical (verified live for all shapes).Not swept:
removeMacroIndirection'smacro.commands || []. That is truthiness, so it already passes an unreadable value through verbatim. Substituting the accessor would replace it with[]at the one migration that then deletessettings.macrosand is flagged complete forever.A separate card rather than reusing
ChoicesUnavailable. Its copy is specifically about the choice tree, andMountFailed's ("include the message below if you report this") is wrong here - nothing threw, and there is no error to report.Validation
pnpm run test- 4595 passing, 0 failures.tsc --noEmit,pnpm lint,svelte-checkall clean.#1566malformed-tree ratchet had no Macro node at all, so two rows that already swept macro-reaching walkers (duplicateChoice→regenerateIds,collectChoiceClosure→packageTraversal) were dead coverage. Grown rather than forked, with malformed commands at every depth including inside conditional branches.malformedSnapshotnow pins command values and arity - a walker may re-key an entry but must never lose one. The newbuildPackagePreviewrow immediately found a pre-existing null-deref, fixed here.An adversarial review of this PR found five defects in the fix itself
All reproduced, all fixed in
b10a8ad, all now covered:macro: []/[cmd]-typeof [] === "object", so a looser guard let the builder writemacro.commands = [...]onto an Array. That is a non-index property, whichJSON.stringify(i.e.saveData) drops: the user added commands, saw them, and every save silently discarded them. A regression vsmaster, which refused to edit at all.commands: []fired a 15s red notice - it is the healthy default (QuickAddMacro's constructor), so every fresh macro, and every launch with an unpopulated run-on-startup macro, would have nagged.[ok, null, ok]still threw on{{MACRO:Name::member}}-commandListOfconverts the non-array shapes; a hole inside a real array does not. The one asymmetric entrypoint.nullin a packaged folder aborted the whole import - and guarding the preview walker earlier in this branch is what made it reachable (the package now previews cleanly, so the user gets to click Import and fail there). Fixed on both the import and export sides."not a list"- its character count.Review round on this PR
Graphite and Codex found four more, all valid, all fixed and covered:
An unreadable macro reported success. Logging and returning let
quickadd:runanswerok: truefor a macro that ran nothing - and onmasterthe array-turned-object shape did throw, so this was a regression in the CLI contract.run()andexecuteConditionalnow throw. The conditional half mattered more: returning only exitedexecuteConditional, so the outer loop carried on with every command after the branch, including file writes that were only ever meant to follow a branch that never ran.and in the GUI:
QuickAdd: (ERROR) Error executing choice obj-cmds: Could not read the commands for macro 'E ObjCommands'. ...The importer wrote
macro.idonto an array (a silent no-op, since JSON drops non-index properties), and readmacro?.commandsoff it - so an array-valued macro's choice references and secret refs were never remapped, and a duplicated choice could still invoke the pre-existing local one. Both now read the new sharedmacroCommandsValueOf, which is also whatMacroBuilder's recovery path reads.The package fixture set
entryChoiceIdswhereQuickAddPackagedeclaresrootChoiceIds, hidden behind anas unknown as. A fixture that does not satisfy the production shape is the drift this sweep exists to catch; the cast is gone.Package-import regression coverage for the recovery paths, driving
applyPackageImportend-to-end. Both behavioural assertions verified to fail without the fix.Filed rather than folded in
master, same class, different surface)macro.commandshides nested choices from migrations, which are then flagged complete foreverhasUnreadableChildrencontradicts its own contract for""/0/falseRelease impact
fix:- patch. No migration, no schema change, no setting. A malformed value is never rewritten; the only write is the user's own edit.Summary by CodeRabbit