Skip to content

fix: a malformed folder in data.json no longer takes the plugin down with it - #1583

Merged
chhoumann merged 11 commits into
masterfrom
chhoumann/1566-settings-resilience
Jul 27, 2026
Merged

fix: a malformed folder in data.json no longer takes the plugin down with it#1583
chhoumann merged 11 commits into
masterfrom
chhoumann/1566-settings-resilience

Conversation

@chhoumann

@chhoumann chhoumann commented Jul 27, 2026

Copy link
Copy Markdown
Owner

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:

TypeError: t is not iterable
  at addCommandsForChoices (plugin:quickadd)
  at addCommandForChoice   (plugin:quickadd)

addCommandForChoice runs straight from onload(), so a single folder in data.json with no choices key cost:

  • every choice command (the palette loses all of them)
  • migrations — never ran
  • the QuickAdd CLI handlers — never registered, so quickadd:list was "command not found"
  • startup macros — never ran
  • the update announcement

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):

before

The real defect

IMultiChoice.choices: IChoice[] is an invariant the code assumes and nothing enforces. data.json is 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:

choice.choices ?? []      // {} is not nullish → passes straight through
if (choice.choices) {}    // {} is truthy      → passes straight through

Only Array.isArray rejects both shapes. So this is a function, not a convention:

helper role
childChoicesOf(choice) READ view. Always safe to iterate/map/spread. Never repairs.
hasChildChoices(choice) WRITE guard. May this node be rebuilt?
hasUnreadableChildren(c) Is the value lossy, or genuinely empty?
rootChoicesOf(value) The same, one level up, for settings.choices.
isChoiceLike(value) A list entry can be null or a primitive.

The part that was easy to get wrong

dedupeChoicesById deliberately preserves a malformed folder rather than fabricating []. Three independent reviewers converged on the same blocking objection to the obvious fix: updateMultiById and updateChoiceHelper re-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:

Reads go through the accessor. A walker that rebuilds a node it is not targeting returns it untouched.

Every case in choiceService.malformed.test.ts asserts 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.

after

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:

delete warning

A corrupt root choices gets 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:

root corruption

Tradeoffs

Read-layer fix, not load-time normalization. Normalizing choices to [] in loadSettings would 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.choices is now optional. strictNullChecks is on and CI runs svelte-check, so the missing-key shape is a compile error at every unguarded read, in .ts and .svelte alike. 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 addCommandsForChoices isolates 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.json would 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. removeMacroIndirection moves 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-check and eslint all clean. main.ts cannot be imported under vitest (it pulls in obsidian), 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 a null list entry:

check result
plugin load no "Plugin failure", 3 choice commands registered
quickadd:list ok:true, all 7 choices, malformed folders listed as empty
migrations 15 complete, zero console errors
settings tab all 8 groups, 7 rows, 1 unreadable-folder hint
collapse + duplicate + rename all three malformed values byte-identical in data.json
add into the {} folder repaired to a real array; the lossy one untouched
delete the lossy folder confirmation warns first
run a folder from a command honest notice, not a dead picker
corrupt root error card, rest of the tab alive, data.json md5-identical after open/close

Reviewed

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

  • An error boundary at the mountComponent seam for every Svelte host (modals, package manager), which needs a per-host fallback story.
  • ChoiceView's async row actions (delete, duplicate, configure) degrade to a silent no-op on throw; they should route through reportError.
  • data.json schema validation with quarantine-and-backup on load — the general answer this deliberately does not build.

Summary by CodeRabbit

  • Bug Fixes
    • Improved robustness across the settings UI, command/URI handling, and editing flows when choice trees are malformed, include holes, or contain unreadable folders.
    • Prevented accidental overwrites by preserving unreadable sections and avoiding rendering/editing gaps that broke drag-and-drop or reordering.
    • Enhanced empty vs unreadable folder messaging, counts, and drill-down navigation.
  • New Features
    • Added an “unavailable choices” screen when choices can’t be safely read.
  • Migrations
    • Migrations now remain pending when the choices tree is unreadable instead of rewriting it.
  • Tests
    • Expanded coverage for malformed traversal and write/read protections across key entry points and services.

@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: abb1b0c
Status: ✅  Deploy successful!
Preview URL: https://a8ac6d3d.quickadd.pages.dev
Branch Preview URL: https://chhoumann-1566-settings-resi.quickadd.pages.dev

View logs

@coderabbitai

coderabbitai Bot commented Jul 27, 2026

Copy link
Copy Markdown

Review Change Stack

Note

Reviews paused

It 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 reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

QuickAdd 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.

Changes

Malformed choice-tree resilience

Layer / File(s) Summary
Safe traversal contracts and fixtures
src/types/choices/IMultiChoice.ts, src/utils/choiceUtils.ts, src/utils/malformedChoices.*, src/utils/*.test.ts
Traversal guards handle malformed roots, holes, missing children, and unreadable folder contents while preserving malformed values.
Choice editing and package traversal
src/services/choiceService.ts, src/services/packageImportService.ts, src/utils/packageTraversal.ts, src/services/*malformed.test.ts
Tree edits and package operations skip invalid nodes, preserve unreadable folders, and repair only safely writable malformed folders.
Settings rendering and folder interaction
src/gui/choiceList/*, src/gui/suggesters/*, src/gui/choiceList/contextMenu.ts, src/choiceExecutor.ts
Settings views and suggesters distinguish empty from unreadable folders and render unavailable states instead of failing.
Application and integration entry points
src/main.ts, src/quickAddSettingsTab.ts, src/cli/*, src/gui/MacroGUIs/MacroBuilder.ts, src/gui/PackageManager/ExportPackageModal.svelte
Startup, mounting, command registration, CLI flattening, and package traversal use guarded helpers and error isolation.
Migration preservation and pending results
src/migrations/*
Migrations leave unreadable roots untouched and return incomplete results instead of persisting fabricated empty arrays.

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
Loading

Possibly related PRs

Suggested labels: released

Poem

I’m a rabbit in the data tree,
Guarding folders carefully.
Holes may hop and roots may bend,
No lost choices at the end.
Empty flags and warnings glow—
Safe burrows help QuickAdd grow!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 61.70% 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 clearly matches the main change: hardening QuickAdd against malformed folder data in data.json.
Linked Issues check ✅ Passed The PR guards the settings tree against malformed Multi choices and preserves deletion/rendering behavior, which directly fixes #1566.
Out of Scope Changes check ✅ Passed The broader startup, migration, and service changes all support the same malformed-data resilience goal and don't 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/1566-settings-resilience

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.

@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: 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".

Comment thread src/migrations/removeMacroIndirection.ts Outdated

@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: 1

🧹 Nitpick comments (4)
src/migrations/helpers/isCaptureChoice.ts (1)

5-9: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Parameter typed IChoice despite being explicitly null/primitive-tolerant.

Sibling predicate isMultiChoice(choice: unknown) types its input as unknown to reflect that it must handle malformed data; this function keeps choice: IChoice even though the comment states it must tolerate null/stray primitives. Widening to unknown would 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's props type is decoupled from the actual component argument.

mountComponent ties props: ComponentProps<C> to the same generic C used for component: C. Here, props is instead typed as Parameters<typeof mountComponent>[2], derived from mountComponent's own separate generic resolution rather than from this function's local C. When TypeScript extracts Parameters<> from a generic (non-overloaded) function type, the type parameter collapses to its constraint — so this resolves to ComponentProps<Component<any, any>>, regardless of which concrete component is passed to mountSettingView. 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/ComponentProps from svelte, mirroring mountComponent'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 hovering props'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 win

Test helper reintroduces the exact unsafe cast this PR removes elsewhere.

walk((choice as IMultiChoice).choices!) bypasses the optional-choices contract with a non-null assertion, throwing at runtime if a Multi choice's choices is missing/non-array — the very malformed shape (#1566) this PR hardens against everywhere else via childChoicesOf(). Every other flattenChoices/traversal in this cohort (including the production one in this same file) was converted to use childChoicesOf; 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 childChoicesOf from ../utils/choiceUtils, dropping the now-unneeded IMultiChoice cast)

Based on learnings, IMultiChoice.choices is documented as optional/untrusted and meant to be read via childChoicesOf(), 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 win

Reuse the canonical choiceUtils helpers instead of reimplementing them inline. Both sites duplicate logic already centralized in choiceUtils.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: in buildChoiceCatalog's walk, replace isMultiChoice(choice) && Array.isArray(choice.choices) (Line 130) with hasChildChoices(choice), and pass childChoicesOf(choice) to the recursive walk call.
  • src/gui/choiceList/contextMenu.ts#L50-L73: in computeEligibleMultiTargets, replace Array.isArray(roots) ? roots : [] (Line 55) with rootChoicesOf(roots), matching isChoiceNested'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

📥 Commits

Reviewing files that changed from the base of the PR and between bf39e2b and 0b5e722.

📒 Files selected for processing (40)
  • src/choiceExecutor.ts
  • src/cli/registerQuickAddCliHandlers.test.ts
  • src/cli/registerQuickAddCliHandlers.ts
  • src/gui/MacroGUIs/MacroBuilder.ts
  • src/gui/PackageManager/ExportPackageModal.svelte
  • src/gui/choiceList/ChoiceList.svelte
  • src/gui/choiceList/ChoiceView.malformed.test.ts
  • src/gui/choiceList/ChoiceView.svelte
  • src/gui/choiceList/ChoicesUnavailable.svelte
  • src/gui/choiceList/MultiChoiceListItem.svelte
  • src/gui/choiceList/contextMenu.ts
  • src/gui/suggesters/choiceSuggester.test.ts
  • src/gui/suggesters/choiceSuggester.ts
  • src/main.ts
  • src/migrations/consolidateFileExistsBehavior.test.ts
  • src/migrations/consolidateFileExistsBehavior.ts
  • src/migrations/helpers/choice-traversal.ts
  • src/migrations/helpers/isCaptureChoice.ts
  • src/migrations/helpers/isMultiChoice.ts
  • src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts
  • src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts
  • src/migrations/removeMacroIndirection.test.ts
  • src/migrations/removeMacroIndirection.ts
  • src/quickAddSettingsTab.ts
  • src/services/choiceService.audit-commands-choicelist.test.ts
  • src/services/choiceService.insertIntoMulti.test.ts
  • src/services/choiceService.malformed.test.ts
  • src/services/choiceService.test.ts
  • src/services/choiceService.ts
  • src/services/packageExportService.test.ts
  • src/services/packageImportService.malformed.test.ts
  • src/services/packageImportService.test.ts
  • src/services/packageImportService.ts
  • src/types/choices/IMultiChoice.ts
  • src/utils/choiceUtils.test.ts
  • src/utils/choiceUtils.ts
  • src/utils/malformedChoices.entrypoints.test.ts
  • src/utils/malformedChoices.fixture.ts
  • src/utils/packageTraversal.ts
  • tests/packages/packageImportService.test.ts

Comment thread src/gui/choiceList/ChoiceList.svelte

@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: 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

📥 Commits

Reviewing files that changed from the base of the PR and between 7f20581 and f06ef4e.

📒 Files selected for processing (2)
  • src/gui/choiceList/ChoiceList.svelte
  • src/gui/choiceList/ChoiceView.malformed.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/gui/choiceList/ChoiceList.svelte

Comment thread src/gui/choiceList/ChoiceView.malformed.test.ts
chhoumann added 11 commits July 27, 2026 09:49
`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
@chhoumann
chhoumann force-pushed the chhoumann/1566-settings-resilience branch from cb0599f to abb1b0c Compare July 27, 2026 07:52

@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: 1

🧹 Nitpick comments (1)
src/utils/packageTraversal.ts (1)

109-154: 📐 Maintainability & Code Quality | 🔵 Trivial | 🏗️ Heavy lift

Same tree-walk-with-hole-guard logic triplicated across files.

The walk in buildChoiceCatalog (skip non-choice-like entries, recurse only into Multi with an array choices) is copied verbatim — including the same source comment — in src/services/choiceService.ts and src/gui/choiceList/contextMenu.ts. Since choiceUtils.ts already centralizes the safe-traversal contract (isChoiceLike, childChoicesOf, rootChoicesOf), consider extracting this walker into a single shared helper (e.g. a walkChoiceTree/buildChoiceIndex utility) 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

📥 Commits

Reviewing files that changed from the base of the PR and between cb0599f and abb1b0c.

📒 Files selected for processing (40)
  • src/choiceExecutor.ts
  • src/cli/registerQuickAddCliHandlers.test.ts
  • src/cli/registerQuickAddCliHandlers.ts
  • src/gui/MacroGUIs/MacroBuilder.ts
  • src/gui/PackageManager/ExportPackageModal.svelte
  • src/gui/choiceList/ChoiceList.svelte
  • src/gui/choiceList/ChoiceView.malformed.test.ts
  • src/gui/choiceList/ChoiceView.svelte
  • src/gui/choiceList/ChoicesUnavailable.svelte
  • src/gui/choiceList/MultiChoiceListItem.svelte
  • src/gui/choiceList/contextMenu.ts
  • src/gui/suggesters/choiceSuggester.test.ts
  • src/gui/suggesters/choiceSuggester.ts
  • src/main.ts
  • src/migrations/consolidateFileExistsBehavior.test.ts
  • src/migrations/consolidateFileExistsBehavior.ts
  • src/migrations/helpers/choice-traversal.ts
  • src/migrations/helpers/isCaptureChoice.ts
  • src/migrations/helpers/isMultiChoice.ts
  • src/migrations/incrementFileNameSettingMoveToDefaultBehavior.ts
  • src/migrations/mutualExclusionInsertAfterAndWriteToBottomOfFile.ts
  • src/migrations/removeMacroIndirection.test.ts
  • src/migrations/removeMacroIndirection.ts
  • src/quickAddSettingsTab.ts
  • src/services/choiceService.audit-commands-choicelist.test.ts
  • src/services/choiceService.insertIntoMulti.test.ts
  • src/services/choiceService.malformed.test.ts
  • src/services/choiceService.test.ts
  • src/services/choiceService.ts
  • src/services/packageExportService.test.ts
  • src/services/packageImportService.malformed.test.ts
  • src/services/packageImportService.test.ts
  • src/services/packageImportService.ts
  • src/types/choices/IMultiChoice.ts
  • src/utils/choiceUtils.test.ts
  • src/utils/choiceUtils.ts
  • src/utils/malformedChoices.entrypoints.test.ts
  • src/utils/malformedChoices.fixture.ts
  • src/utils/packageTraversal.ts
  • tests/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

Comment thread src/gui/choiceList/ChoiceView.malformed.test.ts
@chhoumann
chhoumann merged commit 234a349 into master Jul 27, 2026
13 checks passed
@chhoumann
chhoumann deleted the chhoumann/1566-settings-resilience branch July 27, 2026 07:59
chhoumann added a commit that referenced this pull request Jul 27, 2026
…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.
chhoumann added a commit that referenced this pull request Jul 27, 2026
…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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] A malformed folder in data.json blanks the whole settings tab

1 participant