fix: a malformed folder in data.json no longer takes the plugin down with it - #1583
Conversation
Deploying quickadd with
|
| Latest commit: |
abb1b0c
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://a8ac6d3d.quickadd.pages.dev |
| Branch Preview URL: | https://chhoumann-1566-settings-resi.quickadd.pages.dev |
|
Note Reviews pausedIt looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the Use the following commands to manage reviews:
Use the checkboxes below for quick actions:
📝 WalkthroughWalkthroughQuickAdd now tolerates malformed choice trees across settings rendering, tree edits, package operations, command registration, startup, and migrations. Shared traversal guards preserve unreadable values, distinguish empty from unreadable folders, and prevent malformed data from blanking the settings tab or being replaced with fabricated arrays. ChangesMalformed choice-tree resilience
Estimated code review effort: 4 (Complex) | ~60 minutes Sequence Diagram(s)sequenceDiagram
participant SettingsTab
participant ChoiceView
participant ChoiceUtils
participant ChoiceService
participant SettingsStore
SettingsTab->>ChoiceView: mount choices
ChoiceView->>ChoiceUtils: read root and child choices
ChoiceUtils-->>ChoiceView: readable choices or unavailable state
ChoiceView->>ChoiceService: apply a tree edit
ChoiceService->>ChoiceUtils: validate readable children
ChoiceService-->>SettingsStore: preserve or update choice tree
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 |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 0b5e72277b
ℹ️ 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
🧹 Nitpick comments (4)
src/migrations/helpers/isCaptureChoice.ts (1)
5-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueParameter typed
IChoicedespite being explicitly null/primitive-tolerant.Sibling predicate
isMultiChoice(choice: unknown)types its input asunknownto reflect that it must handle malformed data; this function keepschoice: IChoiceeven though the comment states it must toleratenull/stray primitives. Widening tounknownwould make the signature honest and let TS flag misuse at other call sites.♻️ Suggested signature
-export function isCaptureChoice(choice: IChoice): choice is CaptureChoice { +export function isCaptureChoice(choice: unknown): choice is CaptureChoice {🤖 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/isCaptureChoice.ts` around lines 5 - 9, Update the isCaptureChoice parameter type from IChoice to unknown, matching its null- and primitive-tolerant behavior and the sibling isMultiChoice predicate. Keep the existing isChoiceLike and Capture type-guard logic unchanged.src/quickAddSettingsTab.ts (1)
423-443: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
mountSettingView'spropstype is decoupled from the actualcomponentargument.
mountComponenttiesprops: ComponentProps<C>to the same genericCused forcomponent: C. Here,propsis instead typed asParameters<typeof mountComponent>[2], derived frommountComponent's own separate generic resolution rather than from this function's localC. When TypeScript extractsParameters<>from a generic (non-overloaded) function type, the type parameter collapses to its constraint — so this resolves toComponentProps<Component<any, any>>, regardless of which concrete component is passed tomountSettingView. In practice this means a caller could pass mismatched/incomplete props for the specific mounted component (e.g.ChoiceView,GlobalVariablesView) without a compile error, weakening exactly the kind of static safety this wrapper exists to preserve. Current call sites happen to pass correct props, so there's no active bug today, but the guard rail is gone for future changes.♻️ Proposed fix
- private mountSettingView<C extends Parameters<typeof mountComponent>[1]>( - setting: Setting, - component: C, - props: Parameters<typeof mountComponent>[2], - ): MountHandle | null { + private mountSettingView<C extends Component<any, any>>( + setting: Setting, + component: C, + props: ComponentProps<C>, + ): MountHandle | null {(import
Component/ComponentPropsfromsvelte, mirroringmountComponent's own signature)Since this hinges on how TypeScript's
Parameters<>utility resolves a generic (non-overloaded) function's type parameters, please verify in the actual project TypeScript toolchain (e.g. by hoveringprops's resolved type or adding a deliberately-wrong prop at a call site) that this doesn't already silently accept mismatched props.🤖 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/quickAddSettingsTab.ts` around lines 423 - 443, Update mountSettingView so its props parameter is typed as ComponentProps<C>, coupling it to the method’s existing component generic instead of using Parameters<typeof mountComponent>[2]. Import ComponentProps from the same Svelte package used by mountComponent, and verify that incorrect props at existing or temporary call sites now produce TypeScript errors.src/cli/registerQuickAddCliHandlers.test.ts (1)
36-54: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick winTest helper reintroduces the exact unsafe cast this PR removes elsewhere.
walk((choice as IMultiChoice).choices!)bypasses the optional-choicescontract with a non-null assertion, throwing at runtime if a Multi choice'schoicesis missing/non-array — the very malformed shape (#1566) this PR hardens against everywhere else viachildChoicesOf(). Every otherflattenChoices/traversal in this cohort (including the production one in this same file) was converted to usechildChoicesOf; this local test helper is the one holdout.♻️ Proposed fix
- if (choice.type === "Multi") { - walk((choice as IMultiChoice).choices!); - } + if (choice.type === "Multi") { + walk(childChoicesOf(choice)); + }(and import
childChoicesOffrom../utils/choiceUtils, dropping the now-unneededIMultiChoicecast)Based on learnings,
IMultiChoice.choicesis documented as optional/untrusted and meant to be read viachildChoicesOf(), which this test helper bypasses.🤖 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/cli/registerQuickAddCliHandlers.test.ts` around lines 36 - 54, Update the local test helper flattenChoices and its walk traversal to use childChoicesOf(choice) for Multi choices instead of casting to IMultiChoice and asserting choices is non-null. Import childChoicesOf from ../utils/choiceUtils and remove the now-unused IMultiChoice dependency, preserving traversal behavior for valid child arrays while safely handling missing or malformed choices.Source: Learnings
src/utils/packageTraversal.ts (1)
109-154: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winReuse the canonical
choiceUtilshelpers instead of reimplementing them inline. Both sites duplicate logic already centralized inchoiceUtils.ts, undermining the PR's own stated goal of having one guard definition instead of many ad-hoc ones.
src/utils/packageTraversal.ts#L109-L154: inbuildChoiceCatalog'swalk, replaceisMultiChoice(choice) && Array.isArray(choice.choices)(Line 130) withhasChildChoices(choice), and passchildChoicesOf(choice)to the recursivewalkcall.src/gui/choiceList/contextMenu.ts#L50-L73: incomputeEligibleMultiTargets, replaceArray.isArray(roots) ? roots : [](Line 55) withrootChoicesOf(roots), matchingisChoiceNested's usage two functions above in the same file.♻️ Proposed fixes
--- a/src/utils/packageTraversal.ts @@ - if (isMultiChoice(choice) && Array.isArray(choice.choices)) { - walk(choice.choices, choice.id, path); - } + if (hasChildChoices(choice)) { + walk(childChoicesOf(choice), choice.id, path); + }--- a/src/gui/choiceList/contextMenu.ts @@ - const source: IChoice[] = Array.isArray(roots) ? roots : []; + const source: IChoice[] = rootChoicesOf(roots);🤖 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/packageTraversal.ts` around lines 109 - 154, In src/utils/packageTraversal.ts lines 109-154, update buildChoiceCatalog’s walk to use hasChildChoices(choice) and recurse with childChoicesOf(choice) instead of checking isMultiChoice and choice.choices directly. In src/gui/choiceList/contextMenu.ts lines 50-73, update computeEligibleMultiTargets to use rootChoicesOf(roots) instead of an inline Array.isArray fallback, preserving the existing isChoiceNested helper usage.
🤖 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/gui/choiceList/ChoiceList.svelte`:
- Around line 50-57: Preserve malformed entries filtered by renderable when
applying drag or keyboard reorders: before calling onReorderChoices or assigning
the reordered choices, reintegrate the original list’s holes/non-choice entries
into the reordered choice positions using the existing hole-preservation helper
used elsewhere in the PR. Update both the drag items handling and moveChoice
path, and add a regression test confirming a null or stray primitive survives an
unrelated reorder.
---
Nitpick comments:
In `@src/cli/registerQuickAddCliHandlers.test.ts`:
- Around line 36-54: Update the local test helper flattenChoices and its walk
traversal to use childChoicesOf(choice) for Multi choices instead of casting to
IMultiChoice and asserting choices is non-null. Import childChoicesOf from
../utils/choiceUtils and remove the now-unused IMultiChoice dependency,
preserving traversal behavior for valid child arrays while safely handling
missing or malformed choices.
In `@src/migrations/helpers/isCaptureChoice.ts`:
- Around line 5-9: Update the isCaptureChoice parameter type from IChoice to
unknown, matching its null- and primitive-tolerant behavior and the sibling
isMultiChoice predicate. Keep the existing isChoiceLike and Capture type-guard
logic unchanged.
In `@src/quickAddSettingsTab.ts`:
- Around line 423-443: Update mountSettingView so its props parameter is typed
as ComponentProps<C>, coupling it to the method’s existing component generic
instead of using Parameters<typeof mountComponent>[2]. Import ComponentProps
from the same Svelte package used by mountComponent, and verify that incorrect
props at existing or temporary call sites now produce TypeScript errors.
In `@src/utils/packageTraversal.ts`:
- Around line 109-154: In src/utils/packageTraversal.ts lines 109-154, update
buildChoiceCatalog’s walk to use hasChildChoices(choice) and recurse with
childChoicesOf(choice) instead of checking isMultiChoice and choice.choices
directly. In src/gui/choiceList/contextMenu.ts lines 50-73, update
computeEligibleMultiTargets to use rootChoicesOf(roots) instead of an inline
Array.isArray fallback, preserving the existing isChoiceNested helper usage.
🪄 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: 675232b6-e5df-4a2a-8fc2-d313084d03a2
📒 Files selected for processing (40)
src/choiceExecutor.tssrc/cli/registerQuickAddCliHandlers.test.tssrc/cli/registerQuickAddCliHandlers.tssrc/gui/MacroGUIs/MacroBuilder.tssrc/gui/PackageManager/ExportPackageModal.sveltesrc/gui/choiceList/ChoiceList.sveltesrc/gui/choiceList/ChoiceView.malformed.test.tssrc/gui/choiceList/ChoiceView.sveltesrc/gui/choiceList/ChoicesUnavailable.sveltesrc/gui/choiceList/MultiChoiceListItem.sveltesrc/gui/choiceList/contextMenu.tssrc/gui/suggesters/choiceSuggester.test.tssrc/gui/suggesters/choiceSuggester.tssrc/main.tssrc/migrations/consolidateFileExistsBehavior.test.tssrc/migrations/consolidateFileExistsBehavior.tssrc/migrations/helpers/choice-traversal.tssrc/migrations/helpers/isCaptureChoice.tssrc/migrations/helpers/isMultiChoice.tssrc/migrations/incrementFileNameSettingMoveToDefaultBehavior.tssrc/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.tssrc/migrations/removeMacroIndirection.test.tssrc/migrations/removeMacroIndirection.tssrc/quickAddSettingsTab.tssrc/services/choiceService.audit-commands-choicelist.test.tssrc/services/choiceService.insertIntoMulti.test.tssrc/services/choiceService.malformed.test.tssrc/services/choiceService.test.tssrc/services/choiceService.tssrc/services/packageExportService.test.tssrc/services/packageImportService.malformed.test.tssrc/services/packageImportService.test.tssrc/services/packageImportService.tssrc/types/choices/IMultiChoice.tssrc/utils/choiceUtils.test.tssrc/utils/choiceUtils.tssrc/utils/malformedChoices.entrypoints.test.tssrc/utils/malformedChoices.fixture.tssrc/utils/packageTraversal.tstests/packages/packageImportService.test.ts
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/gui/choiceList/ChoiceView.malformed.test.ts`:
- Around line 129-142: Strengthen the test “drops entries that cannot be keyed
instead of losing the whole list” by asserting that both malformed entries—`{}`
and `{ name: "No id" }`—are absent from the rendered DOM, not only that one
keyed row remains. Keep the existing Survivor assertions and add checks against
the rendered output or relevant labels/text to verify neither malformed entry is
displayed.
🪄 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: 28e6d25b-f159-4063-8bea-5aa7cd0243d0
📒 Files selected for processing (2)
src/gui/choiceList/ChoiceList.sveltesrc/gui/choiceList/ChoiceView.malformed.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- src/gui/choiceList/ChoiceList.svelte
`IMultiChoice.choices` is an invariant the code assumes and nothing
enforces. `data.json` is untrusted input, so a hand-edit, an imported
package or a sync that merges whole files can leave a folder's children
missing, null, or an object where an array belongs - and every reader
guarded (or forgot to) on its own, with two guards that look right and
are not: `?? []` and `if (choice.choices)` both wave `{}` straight
through. Only `Array.isArray` rejects both shapes.
Adds the accessors that make that a function instead of a convention:
childChoicesOf() READ view; always safe to iterate/map/spread
hasChildChoices() WRITE guard; may this node be rebuilt?
hasUnreadableChildren() is the value lossy, or genuinely empty?
rootChoicesOf() the same, one level up, for settings.choices
isChoiceLike() a list entry can be null or a primitive
The read/write split is load-bearing. `dedupeChoicesById` deliberately
preserves a malformed folder rather than fabricating `[]`, so a walker
that REBUILDS a node it is not targeting must pass it through untouched
- substituting the read accessor there would persist `[]` over the
original value on the next unrelated save, which is exactly the outcome
the preservation stance exists to avoid.
`dedupeChoicesById` also gains element tolerance: it runs inside
loadSettings, ~200 lines before addSettingTab, so a `null` in the list
threw before the settings tab was ever registered.
Also declares `IMultiChoice.choices` optional. strictNullChecks is on and
CI runs svelte-check, so the missing-key shape is now a compile error at
every unguarded read in .ts and .svelte alike.
Refs #1566
A folder in data.json with no `choices` key threw "TypeError: choices is not iterable" out of addCommandForChoice, which runs straight from onload. Obsidian reported only "Plugin failure: quickadd" and everything after that line was lost: no choice commands, migrations never ran, the CLI handlers never registered (so `quickadd:list` was "command not found"), startup macros never ran. The settings tab had already been registered a few lines earlier, which is the only reason the bug looked like "the settings tab is blank". Routes the four tree walks in main.ts through the accessors, and fault-isolates each choice-dependent onload step so the blast radius of a data defect is one capability, not the plugin. The per-choice try/catch in addCommandsForChoices is the same bound for corrupt shapes the accessors do not know about yet. main.ts cannot be imported under vitest (it pulls in `obsidian`), so this half is proven live in an isolated vault rather than by unit test. Refs #1566
The tree walkers in choiceService had to satisfy two things at once over
a preserved malformed folder: never throw on it, and never rewrite it.
The second is the one that is invisible until it has already happened.
updateMultiById re-spreads EVERY folder it walks past, not just the one
it targets (its doc comment claiming otherwise was wrong), and it is on
the save path - setMultiCollapsedById, setFolderChildrenById. So reading
its children through the total accessor would have persisted `[]` over
the preserved value the first time the user collapsed an unrelated
folder. Same shape in removeChoiceById, insertIntoMulti, insertChoiceAfter.
So: reads go through childChoicesOf, and any walker that REBUILDS a node
it is not targeting returns it untouched instead (hasChildChoices).
Writing into a malformed folder is allowed only when its value was
carrying nothing - `{}`, null, no key at all - which makes the folder
usable again; when the value could still hold choices the write is
refused so the caller falls back rather than destroying it. Duplicating
carries an unreadable value across verbatim, and the delete confirmation
now says so before the folder goes.
The walkers also step over a hole in the list itself (a `null` or a
stray primitive) rather than dereferencing it.
Every case in the new suite asserts the malformed values are identical
afterwards, not merely that nothing threw - that assertion is the one
that fails on the naive fix.
Refs #1566
The rest of the reach: running a folder from its command, the launcher's
nested search, the context menu, the macro builder, the CLI, and the
migrations.
Two of these were worse than a throw.
choiceExecutor read `multiChoice.choices.length === 0`, and `({}).length`
is `undefined`, so the empty-folder guard slid past and handed the picker
a non-list - a dead picker instead of the honest "this folder is empty"
notice. And migrations/helpers/isMultiChoice gated on
`choices !== undefined`, which `{}` satisfies, so two write migrations
recursed into it and threw; migrate() then reverted and left the flag
unset, so those migrations re-ran and re-failed on every single launch,
logging a "please create an issue" error each time.
consolidateFileExistsBehavior went the other way and replaced a corrupt
root `choices` with [], which the next save persisted. A migration should
not destroy the data a user needs to recover by hand; it now walks past
what it cannot read. Its test is inverted to lock that in.
Also drops the CLI's private copy of flattenChoices - a second place to
forget the guard - in favour of the shared, now-total one.
Refs #1566
The reported symptom. MultiChoiceListItem dereferenced choice.choices in
three places, and a throw during MOUNT does not cost one row: it escapes
mount() and abandons the rest of the declarative settings tab, so
QuickAdd's settings came up as a lone "Choices & packages" heading with
nothing under it - no choices, no add controls, and none of the other
eight setting groups.
The list now reads its children through one $derived accessor, filters
list holes out of the keyed {#each} and the dnd zone, and renders a
folder that lost nothing as an ordinary empty folder - which post-#1569
already shows the "Empty - add a choice or drag one here." hint, so it
can be refilled, renamed, moved and deleted. Dropping a choice in
repairs the value.
A folder whose value could still be HOLDING choices is the one case
where "empty" would be a lie, so it says what happened in that same
hint slot and offers neither the drop zone nor the add row: the two
affordances that would overwrite it.
Two backstops for the class rather than this instance, since this is its
third occurrence (#1451, #1507, #1566): a <svelte:boundary> inside
ChoiceView, and a try/catch around the settings tab's Svelte mounts so
one broken view can never take the other groups down with it. Both show
the same card - what failed, where the file is, and that QuickAdd has
not touched it. No retry button: it would re-render the same data, and
anything that could rewrite data.json would destroy the value the user
needs to recover.
A corrupt ROOT `choices` renders that card too, rather than the "No
choices yet" hero whose single CTA would write a fresh list over it.
Refs #1566
The bug was never one unguarded dereference. data.json is untrusted and roughly forty readers each decided for themselves whether to guard, using guards that look right and are not - so fixing the readers we know about does nothing about the next one. One fixture tree carrying every malformed shape, pushed through every exported walker. Each asserts twice: nothing throws, AND the malformed values are identical afterwards. The second assertion is what catches a "fix" that quietly repairs the tree to [] and lets the next save destroy it; without it the whole preservation stance is decorative. The sweep immediately earned its keep, finding two readers the audit had missed: computeEligibleMultiTargets (so a single list hole meant NO row in the settings list had a context menu) and packageTraversal's choice catalog (so package export/import walked into it). Adding a new walker to the list costs one line. Refs #1566
Found live, not by the unit suite: main.ts cannot be imported under vitest (it pulls in `obsidian`), so its walkers are only reachable in a real vault. A `null` entry in data.json's choice list made getChoiceById / getChoiceByName throw, which breaks every registered choice command's callback and the URI handler. The same hole reverted two migrations, and a migration that throws is not a one-time failure: migrate() restores the backup and leaves the flag unset, so it re-runs and re-fails on every launch forever, logging a "please create an issue" error each time. Also stops three more migrations from replacing a corrupt ROOT `choices` with [], for the same reason consolidateFileExistsBehavior stopped: the next save would persist it. And makes the picker's notice tell the same story the settings list does. "Folder X is empty" is true for a folder that lost nothing, and a lie for one whose contents merely could not be read - so both surfaces now read the one predicate rather than disagreeing about the same folder. Refs #1566
Three reviewers attacked the branch and 18 findings survived verification.
The most important was a regression this change introduced:
removeMacroIndirection MOVES legacy macros into the choice tree and then
deletes the old `macros` array, so making flattenChoices total turned
"throws, reverts, retries next launch" into "completes, and every legacy
macro is silently gone forever" - the exact failure direction the change
exists to prevent. It now returns { complete: false } and destroys
nothing, so a vault repaired by hand still gets migrated. The three
sibling migrations that skip an unreadable root stay pending for the same
reason.
The rest were readers and writers the first pass missed, all found the
same way: the fixture only placed corruption at the ROOT of the tree, so
a walker that guards its entry point but not its recursion passed. With
corruption at every depth the sweep immediately caught duplicateChoice,
packageTraversal's dependency collection, and the package-import writers
- which fabricated [] over an unreadable value, the one invariant the
whole change turns on.
Also: the export modal's descendant walk (a `?? []` guard, the exact
anti-pattern documented in choiceUtils), the keyboard reorder path (fixed
for rendering but not for ArrowUp/ArrowDown), MacroBuilder's flatten, and
ChoiceView's subtreeHasCommand.
Two consistency fixes so the surfaces stop disagreeing about the same
folder: "Move to" no longer offers a folder that would refuse the write,
and the picker marks an unreadable folder "Unreadable" rather than
"Empty".
Refs #1566
Codex caught this on the PR: the previous guard only asked about the ROOT. With a valid root array but an unreadable NESTED folder, the migration ran to completion - and the now-total flattenChoices could not see that folder's descendants, so a legacy macro one of them referenced looked orphaned, got duplicated at the root, and `settings.macros` was deleted. The hidden choice keeps a dangling macroId forever, because a completed migration never retries. Same class as the bug being fixed, one level down. treeHasUnreadableChildren asks the question about the whole tree, and the four migrations that skip what they cannot read now stay pending on it. Refs #1566
Following a CodeRabbit thread about the render filter, an adjacent case
turned out to be worse than the one it raised: an entry that IS an object
but has no `id`. svelte-dnd-action reads `.id` on every item, and the
keyed {#each} needs it present and unique - so two id-less entries raise
`each_key_duplicate`, the #1451 crash. The boundary added in this PR
catches it, but the user still loses every real row with it.
Filtering them out at render degrades far better: the junk rows vanish
and the real choices stay. Nothing is rewritten - the entry stays in
data.json, and it cannot be hiding a choice, since only a Multi's
`choices` value can and that is preserved separately.
Refs #1566
Per review: counting keyed rows would still pass if a regression painted the junk entries as rows without a data-choice-id. Assert both are absent from the DOM. Verified the strengthened test fails with the filter reverted. Refs #1566
cb0599f to
abb1b0c
Compare
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (1)
src/utils/packageTraversal.ts (1)
109-154: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy liftSame tree-walk-with-hole-guard logic triplicated across files.
The
walkinbuildChoiceCatalog(skip non-choice-like entries, recurse only intoMultiwith an arraychoices) is copied verbatim — including the same source comment — insrc/services/choiceService.tsandsrc/gui/choiceList/contextMenu.ts. SincechoiceUtils.tsalready centralizes the safe-traversal contract (isChoiceLike,childChoicesOf,rootChoicesOf), consider extracting this walker into a single shared helper (e.g. awalkChoiceTree/buildChoiceIndexutility) so future hole-handling fixes only need to land once instead of being kept in sync across three call sites.🤖 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/packageTraversal.ts` around lines 109 - 154, Extract the duplicated choice-tree traversal from buildChoiceCatalog and its counterparts in choiceService.ts and contextMenu.ts into a shared helper in choiceUtils.ts, such as walkChoiceTree or buildChoiceIndex. Preserve the existing isChoiceLike hole guard, rootChoicesOf/childChoicesOf traversal behavior, and recursion only for multi-choice nodes with array choices; update all three call sites to use the shared helper.
🤖 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/gui/choiceList/ChoiceView.malformed.test.ts`:
- Around line 95-99: Extend the malformed-choice regression test to also assert
that the nested-folder creation CTA is unavailable, alongside the existing
nested element and “Add a choice to Broken” assertions. Use the relevant
folder-creation label or query helper already used by the ChoiceView test, and
verify it resolves to null.
---
Nitpick comments:
In `@src/utils/packageTraversal.ts`:
- Around line 109-154: Extract the duplicated choice-tree traversal from
buildChoiceCatalog and its counterparts in choiceService.ts and contextMenu.ts
into a shared helper in choiceUtils.ts, such as walkChoiceTree or
buildChoiceIndex. Preserve the existing isChoiceLike hole guard,
rootChoicesOf/childChoicesOf traversal behavior, and recursion only for
multi-choice nodes with array choices; update all three call sites to use the
shared helper.
🪄 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: 060c7a08-3211-49cd-a127-65bfabdd60a7
📒 Files selected for processing (40)
src/choiceExecutor.tssrc/cli/registerQuickAddCliHandlers.test.tssrc/cli/registerQuickAddCliHandlers.tssrc/gui/MacroGUIs/MacroBuilder.tssrc/gui/PackageManager/ExportPackageModal.sveltesrc/gui/choiceList/ChoiceList.sveltesrc/gui/choiceList/ChoiceView.malformed.test.tssrc/gui/choiceList/ChoiceView.sveltesrc/gui/choiceList/ChoicesUnavailable.sveltesrc/gui/choiceList/MultiChoiceListItem.sveltesrc/gui/choiceList/contextMenu.tssrc/gui/suggesters/choiceSuggester.test.tssrc/gui/suggesters/choiceSuggester.tssrc/main.tssrc/migrations/consolidateFileExistsBehavior.test.tssrc/migrations/consolidateFileExistsBehavior.tssrc/migrations/helpers/choice-traversal.tssrc/migrations/helpers/isCaptureChoice.tssrc/migrations/helpers/isMultiChoice.tssrc/migrations/incrementFileNameSettingMoveToDefaultBehavior.tssrc/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.tssrc/migrations/removeMacroIndirection.test.tssrc/migrations/removeMacroIndirection.tssrc/quickAddSettingsTab.tssrc/services/choiceService.audit-commands-choicelist.test.tssrc/services/choiceService.insertIntoMulti.test.tssrc/services/choiceService.malformed.test.tssrc/services/choiceService.test.tssrc/services/choiceService.tssrc/services/packageExportService.test.tssrc/services/packageImportService.malformed.test.tssrc/services/packageImportService.test.tssrc/services/packageImportService.tssrc/types/choices/IMultiChoice.tssrc/utils/choiceUtils.test.tssrc/utils/choiceUtils.tssrc/utils/malformedChoices.entrypoints.test.tssrc/utils/malformedChoices.fixture.tssrc/utils/packageTraversal.tstests/packages/packageImportService.test.ts
🚧 Files skipped from review as they are similar to previous changes (33)
- src/cli/registerQuickAddCliHandlers.test.ts
- src/gui/MacroGUIs/MacroBuilder.ts
- src/services/packageExportService.test.ts
- src/migrations/helpers/isMultiChoice.ts
- src/gui/choiceList/ChoicesUnavailable.svelte
- src/migrations/removeMacroIndirection.test.ts
- src/gui/choiceList/MultiChoiceListItem.svelte
- src/types/choices/IMultiChoice.ts
- src/services/packageImportService.test.ts
- src/utils/malformedChoices.entrypoints.test.ts
- src/migrations/consolidateFileExistsBehavior.test.ts
- src/choiceExecutor.ts
- src/services/choiceService.insertIntoMulti.test.ts
- src/quickAddSettingsTab.ts
- src/gui/PackageManager/ExportPackageModal.svelte
- src/migrations/helpers/choice-traversal.ts
- src/cli/registerQuickAddCliHandlers.ts
- tests/packages/packageImportService.test.ts
- src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts
- src/utils/malformedChoices.fixture.ts
- src/gui/choiceList/contextMenu.ts
- src/migrations/removeMacroIndirection.ts
- src/services/choiceService.malformed.test.ts
- src/utils/choiceUtils.ts
- src/gui/suggesters/choiceSuggester.ts
- src/main.ts
- src/gui/choiceList/ChoiceList.svelte
- src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts
- src/gui/suggesters/choiceSuggester.test.ts
- src/migrations/consolidateFileExistsBehavior.ts
- src/gui/choiceList/ChoiceView.svelte
- src/services/packageImportService.ts
- src/services/choiceService.ts
…and silently (#1592) * fix: a broken view no longer takes its whole screen down with it A Svelte component that threw during mount escaped into whatever was hosting it. A Modal that mounts from its constructor or onOpen() never opened at all; the declarative settings tab, which builds every group by calling render closures in turn, abandoned every remaining QuickAdd setting. #1583 caught this at the settings tab's own two call sites; the other five hosts - both choice builders, the macro command editor and the two package modals - had nothing. Live repro: a Macro choice whose macro.commands is null in data.json. Clicking Configure threw "Cannot read properties of null (reading 'filter')" out of CommandList's mount, and the macro builder simply did not appear - no modal, no Notice, nothing. mountComponent now never throws. It reports the error once (one Notice, through the plugin's own reportError), clears the debris the half-finished mount left in the host, and puts a card where the component would have been. The macro builder opens, says exactly what could not be displayed and why, and the rest of it - name, command bar, script pickers, autosave footer - still works. Each host names what it is showing, so the message is specific. The settings choice list keeps its own card (ChoicesUnavailable), which carries the data.json recovery instructions a generic one has no business claiming; that card and the default now share one prop contract. The handle reports whether the component actually rendered. The choice builders use it to drop a form the user never saw, so it cannot resolve its untouched clone of the choice back over their data at close. Not covered, and now documented at the seam: a throw from an $effect. Svelte flushes effects on a later tick, so it never reaches this try/catch. That case belongs to <svelte:boundary>, which ChoiceView already has. Closes #1584 * fix: a failing choice-list button says so instead of doing nothing The row actions in the choices list were async handlers with no catch. Svelte re-throws an event handler's error to the window, and a rejected promise is an unhandled rejection - Obsidian surfaces neither. So a failing Delete, Configure, Duplicate, Rename or Move produced nothing at all: no Notice, no visual change, no message anyone would think to look for. The button just did not work, which reads as "QuickAdd is broken" and gives the user nothing to report. Live repro: a choice whose type is not a known choice type (a hand-edit, or an import from a newer QuickAdd). Configure routes to getChoiceBuilder, which throws "Invalid choice type" - and nothing happened. reportingHandler wraps a handler so both halves are covered, the synchronous throw and the rejected promise, and reports through the same reportError the rest of the plugin uses. Cancellations stay silent: a dismissed prompt is an answer, not a failure (#1567). Applied to the whole ChoiceListActions bag rather than to individual buttons. Every row button, context-menu item and nested list reaches the handlers through that one object (MultiChoiceListItem spreads it), so wrapping it there is what makes the coverage structural instead of a list someone has to remember to extend. The message names the action and takes its noun from the row itself, so a folder is never called a choice (#1552). <svelte:boundary> does not help here: it catches render and effect errors, not event handlers. Closes #1585 * fix: don't let a macro be edited through a command list it can't draw Adversarial review of the two previous commits found the state they created but did not finish. Once mountComponent stopped letting a broken CommandList take the macro builder down with it, the builder started opening OVER a list it could not draw - with every add control still live. Two ways that goes wrong: - macro.commands is null (the #1584 repro): every add control throws [...null] inside its click handler. Dead buttons, silently - the exact shape #1585 exists to eliminate. - a valid array with duplicate ids: the keyed {#each} throws each_key_duplicate, so the list is invisible but the add controls WORK. The user clicks Wait three times because nothing appears to happen, closes, and now has three commands in their macro that they cannot see, reorder or delete. Neither was reachable before, because the modal never opened at all. So the editor now stops offering the affordances the failed view was the only way to review: the card explains what happened, and the macro's name, "run on startup" and icon stay editable around it. Same rule the choice builders already follow with handle.ok. Also from the review: - The card's copy claimed "nothing in your vault has been changed" and "everything else on this screen still works". Both can be false - adding a choice saves it and THEN opens the builder, and a host's remaining controls may only have made sense beside the view that failed. It now says what happened, that the failure itself changed nothing, and what to include in a report. - Row-action failures name the row: Couldn't delete the choice "Daily note", not "that choice". The noun still comes from the row, so a folder is a folder. Declined: folding MountFailed and ChoicesUnavailable into one card. Their style blocks are duplicated, but they are deliberately different messages - one carries data.json recovery instructions that would be misleading anywhere else - and restructuring an already-shipped component to share 40 lines of CSS is a bigger change than the duplication costs. * fix: report a thenable's real rejection, not "catch is not a function" reportingHandler duck-typed on `.then` and then called `.catch`. A thenable is only required to have `.then`, so for a non-promise thenable the wrapper threw inside its own success path and reported "result.catch is not a function" in place of the actual failure - the same lost error the helper exists to prevent. Promise.resolve() assimilates any thenable, so the rejection that reaches the Notice is the real one. The regression test asserted only that SOMETHING was logged, which is why it passed over the bug; it now asserts the cause, and fails against the old code. Caught by CodeRabbit on #1592.
…rable (#1616) * refactor(macros): give a macro's command list total read accessors `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 * fix(macros): a malformed command list no longer costs the user the macro 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 * fix(macros): a macro that can't be read fails loudly instead of throwing 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 * test(macros): extend the malformed-tree ratchet to macros and command 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 * fix(macros): close the holes an adversarial review found in the fix itself 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 * fix(macros): an unreadable macro fails the run instead of reporting success 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 * test(macros): type the package fixture properly instead of casting past 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 * test(packages): cover the macro and folder recovery paths through a real 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
Fixes #1566.
The problem is bigger than the title
The issue says a malformed folder blanks the settings tab. It does — but that is the second thing that goes wrong. Reproducing it in an isolated Obsidian 1.13 vault showed the plugin never finishes loading at all:
addCommandForChoiceruns straight fromonload(), so a single folder indata.jsonwith nochoiceskey cost:quickadd:listwas "command not found"Obsidian reported only "Plugin failure: quickadd". The settings tab had been registered a few lines earlier, which is the only reason the bug looks like "the settings tab is blank".
Before — the whole tab, not just one row (the other eight setting groups are gone too):
The real defect
IMultiChoice.choices: IChoice[]is an invariant the code assumes and nothing enforces.data.jsonis untrusted input: hand-edits, imported packages, Obsidian Sync's whole-file last-write-wins, partial writes. An audit found ~40 readers, each deciding for itself whether to guard — with two guards that look right and are not:Only
Array.isArrayrejects both shapes. So this is a function, not a convention:childChoicesOf(choice)hasChildChoices(choice)hasUnreadableChildren(c)rootChoicesOf(value)settings.choices.isChoiceLike(value)nullor a primitive.The part that was easy to get wrong
dedupeChoicesByIddeliberately preserves a malformed folder rather than fabricating[]. Three independent reviewers converged on the same blocking objection to the obvious fix:updateMultiByIdandupdateChoiceHelperre-spread every folder they walk past — not just the one they target — and both are on the save path. Reading their children through the total accessor would have persisted[]over the preserved value the first time the user collapsed an unrelated folder. Exactly the data loss the preservation stance exists to prevent, triggered by an innocuous click instead of by load.So the rule the change turns on:
Every case in
choiceService.malformed.test.tsasserts the malformed values are byte-identical afterwards, not merely that nothing threw. That second assertion is what fails on the naive fix — I verified it does by reverting each guard in turn.What the user sees now
A folder that lost nothing — no key,
null,{}— renders as an ordinary empty folder, with the "Empty — add a choice or drag one here." hint from #1569. It can be renamed, moved, deleted, and refilled; dropping a choice in repairs the value.A folder whose value could still be holding choices (
{"0": {...}}) is the one case where "empty" would be a lie. It says so, and offers neither the drop zone nor the add row — the two affordances that would overwrite it.The same predicate drives the hint, the picker's row marker and the delete confirmation, so the three surfaces cannot disagree about the same folder:
A corrupt root
choicesgets an error card rather than the "No choices yet" hero — whose single CTA would write a fresh list straight over it. The rest of the settings tab still renders:Tradeoffs
Read-layer fix, not load-time normalization. Normalizing
choicesto[]inloadSettingswould be a one-file change and make every downstream reader correct for free. Rejected: the next ordinary save would persist that[]over a value the user may still be able to recover by hand, and QuickAdd offers no other way to get it back. The read layer keeps the file untouched. The cost is a wider diff, which the sweep test exists to make safe.IMultiChoice.choicesis now optional.strictNullChecksis on and CI runssvelte-check, so the missing-key shape is a compile error at every unguarded read, in.tsand.sveltealike. It cost 14 source sites (all of which needed converting anyway) and some!in test assertions. The non-array shape can't be expressed in the type system, which is why the accessor still has to exist.onload fails open. Each choice-dependent step is individually guarded, and
addCommandsForChoicesisolates per choice. The accessors handle the corrupt shapes we know about; this bounds the blast radius of the ones nobody has thought of yet. One defect should cost one command, not the plugin.No retry button on the error card. A retry re-renders the same data and throws again, and anything that could rewrite
data.jsonwould destroy the value the user needs. The card names the file and says QuickAdd has not touched it.Migrations that can't finish stay pending.
removeMacroIndirectionmoves legacy macros into the choice tree and then deletes the old array — with an unreadable root there is nowhere to move them, so finishing would delete them outright, permanently, since migrations are flagged once and never retried. It returns{ complete: false }instead. (An adversarial reviewer caught this as a regression this branch introduced; before the fix the throw at least left the data intact.)What stops the 41st reader
src/utils/malformedChoices.entrypoints.test.ts: one fixture tree carrying every malformed shape at every depth, pushed through every exported walker, asserting both no-throw and blob equality. Adding a new walker costs one line.It earned its keep twice. The first version only placed corruption at the root, and a reviewer pointed out that a walker which guards its entry point but not its recursion passes that happily. Nesting the fixture immediately caught
duplicateChoice,packageTraversal, and the package-import writers — which fabricated[]over an unreadable value, the exact invariant the change turns on.Validation
4189 tests,tsc,svelte-checkandeslintall clean.main.tscannot be imported under vitest (it pulls inobsidian), so its half is proven live rather than by unit test.Live, in an isolated Obsidian 1.13 vault seeded with a folder missing the key, one with
{}, one with{"0": {...a real choice...}}, and anulllist entry:quickadd:listok:true, all 7 choices, malformed folders listed as emptydata.json{}folderdata.jsonmd5-identical after open/closeReviewed
An ultracode design review (3 audit passes + 3 adversarial critiques + synthesis) reshaped the design before any code was written — the read/write split, the
[null]load-path bug, and the root-corruption case all came from it. A second adversarial pass over the implementation produced 26 findings, 18 of which survived independent verification and are fixed here, including the blocking migration regression above.Filed separately, not grown into this diff
mountComponentseam for every Svelte host (modals, package manager), which needs a per-host fallback story.delete,duplicate,configure) degrade to a silent no-op on throw; they should route throughreportError.data.jsonschema validation with quarantine-and-backup on load — the general answer this deliberately does not build.Summary by CodeRabbit