Skip to content

Port: port/pr-27-fix/readjson-deep-merge#6

Merged
briansumma merged 3 commits into
developfrom
port/pr-27-fix/readjson-deep-merge
May 14, 2026
Merged

Port: port/pr-27-fix/readjson-deep-merge#6
briansumma merged 3 commits into
developfrom
port/pr-27-fix/readjson-deep-merge

Conversation

@briansumma
Copy link
Copy Markdown
Collaborator

Ported from upstream cytostack/openwolf. Needs review before merge.

readJSON's fallback was whole-file-only: it returned the fallback
object only when the file read/parse threw. If .wolf/config.json
exists and parses but is missing a nested section (e.g. an older
config written before dashboard/daemon/cron were added), every
accessor like `config.openwolf.dashboard.port` throws
`TypeError: Cannot read properties of undefined`.

Make readJSON deep-merge the parsed value over the fallback:
loaded values always win, but missing nested keys fall through to
the caller-supplied defaults. Arrays and scalars are replaced
wholesale — only plain objects are merged. File read/parse
failures still return the fallback as-is (unchanged behavior).

Net effect: every existing readJSON call site becomes tolerant of
older/partial configs without any call-site changes. Fixes
`openwolf dashboard` and `openwolf daemon *` crashes when a user's
.wolf/config.json predates a section the current release reads.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@briansumma briansumma mentioned this pull request May 14, 2026
@briansumma
Copy link
Copy Markdown
Collaborator Author


PR #6 Review: readJSON Deep Merge

Overall verdict: Do not merge as-is — one blocker, one warning.


What the PR does

Adds a deepMergeDefaults helper that fills missing nested keys from the fallback argument into whatever was parsed from disk. The goal is forward-compatibility: a user's config.json that predates a new section won't cause TypeError: Cannot read properties of undefined anymore. The algorithm itself is correct.


🔴 BLOCKER — src/hooks/shared.ts::readJSON not updated

This is the critical omission. Your CLAUDE.md documents that hooks cannot import from src/utils/ at runtime — they compile to standalone Node scripts and get a self-contained copy of utilities in src/hooks/shared.ts. That file has its own readJSON at line 22–28 that was not touched by this PR:

// shared.ts — still the old, unfixed version
export function readJSON<T = unknown>(filePath: string, fallback: T): T {
  try {
    return JSON.parse(fs.readFileSync(filePath, "utf-8")) as T;
  } catch {
    return fallback;
  }
}

All six hooks (session-start, stop, pre-read, post-read, pre-write, post-write) import from ./shared.js. They fire on every Claude Code event and are the primary runtime path — yet they remain unprotected. The fix is to replicate isPlainObject + deepMergeDefaults into shared.ts and apply the same change there.


🟡 WARNING — Shallow spread shares nested default object references

In deepMergeDefaults at line 21:

const result: Record<string, unknown> = { ...(defaults as Record<string, unknown>) };

{ ...defaults } is a shallow copy. Any nested object in defaults that has no corresponding key in loaded is placed into result by the same reference as the fallback. Consider wolf-daemon.ts:27 where the fallback is a module-level object literal with a nested cron: { enabled: true, heartbeat_interval_minutes: 30 }. If the config file is missing the cron key entirely, result.cron is the fallback's actual cron object. Any code that mutates config.openwolf.cron would silently corrupt the fallback.

The risk is low in the current codebase since configs are treated as read-only, but the contract is fragile. Fix: use structuredClone(defaults) (Node 17+) or a recursive clone helper instead of the spread.


✅ What's correct

  • The isPlainObject guard correctly excludes null, arrays, and class instances — none of which should be recursively merged, all of which JSON.parse can return.
  • "Loaded values always win" semantics are implemented correctly.
  • Scalar/array values in loaded replace defaults wholesale — correct and documented.
  • The catch → return fallback path for missing/corrupt files is unchanged.
  • Both helpers are unexported (private implementation detail) — appropriate.

Full review written to PR-6-REVIEW.md in the repo root.

@briansumma
Copy link
Copy Markdown
Collaborator Author

✅ Autofixer applied. Build and tests passed. Promoting to Ready for Review.

@briansumma briansumma marked this pull request as ready for review May 14, 2026 21:37
@briansumma briansumma changed the base branch from main to develop May 14, 2026 23:46
@briansumma briansumma merged commit 296867e into develop May 14, 2026
@briansumma briansumma deleted the port/pr-27-fix/readjson-deep-merge branch May 14, 2026 23:49
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.

2 participants