Repository navigation
fix(install): never wipe a hook settings file it cannot read as strict JSON - #1157
2 commits merged into
Conversation
…t JSON All four hook writers read their file with json.loads. A comment or trailing comma made that fail, the error was only logged, and the file was overwritten with nothing but our hooks and no backup, so every setting in it was lost. They now share one path: JSONC is read as JSONC, a commented file gets only what changed spliced in, and a file that cannot be merged is left byte for byte and named in the output. Cursor's file now gets a backup like the others. jsonc.set_member no longer moves a trailing comment onto a new member or drops a comment from an empty object. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
code-review-graph reviewOverall risk: 0.55 (MEDIUM) — 39 changed function(s)/class(es), 2 affected flow(s), 5 test gap(s) (0 with no tested caller found, 5 reached only through a caller) Risk-scored changes
Affected execution flows
Reached only through a callerNo test names these directly, but a tested caller reaches them along the call path shown. That is a path in the call graph, not a record of execution -- the caller's tests may never take this branch. Treat it as where to look, not as coverage: these count as gaps and are scored as untested.
Token savings: this graph-backed report used ~58,753 fewer tokens (~40%) than reading every changed file in full (estimated, chars/4 approximation). Powered by code-review-graph — local-first analysis; no code leaves the CI runner. |
There was a problem hiding this comment.
🟡 Changes recommended
Unterminated JSONC comments can leave hooks inactive despite reporting success, and rejected Gemini installs can still overwrite executable hook scripts.
2 open issues
What changed in this PR
This PR protects user settings during hook installation by sharing JSONC-aware merge logic across six hook files, addressing the data-loss issue identified while developing #1155.
Changes:
- Preserve comments and user settings, back up edits, and refuse files that cannot be safely merged.
- Report hook installation success only when the writer succeeds.
- Improve JSONC comment placement and add regression coverage for preservation, refusal, and reinstall behavior.
| File | Description |
|---|---|
| tests/test_skills.py | Require corrupt Cursor settings to remain unchanged. |
| tests/test_jsonc_removals.py | Test comment placement and detection within values. |
| tests/test_installer_ownership.py | Cover safe merges and refusal across six hook writers. |
| tests/test_cli_install.py | Check that refused hooks are not reported as installed. |
| code_review_graph/skills.py | Centralize JSONC reading, safe merging, backups, and refusal reporting. |
| code_review_graph/jsonc.py | Preserve comments during member insertion and detect comments within values. |
| code_review_graph/cli.py | Make installation messages conditional on successful writes. |
| CHANGELOG.md | Document hook-settings protection and JSONC fixes. |
Review effort: Balanced
| stripped = _strip_jsonc(raw) | ||
| if not stripped.strip(): | ||
| return raw, {} |
There was a problem hiding this comment.
Fixed in cc14b85: a file with a /* that never closes is now refused and left byte for byte as it was, including a comment-only file, with tests for all six writers.
| if not _write_merged_settings(settings_path, raw, existing, label): | ||
| return None |
There was a problem hiding this comment.
Fixed in cc14b85: Gemini now writes its hooks folder and scripts only after the settings merge succeeds, as Cursor does, and a test checks a refused install keeps the scripts it found.
… last A /* with no */ made the hooks land inside the comment: written, reported as installed, never run. A refused Gemini CLI install still rewrote the two hook scripts the unchanged settings may run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
#1157 in the train rewrote the hook settings reads and writes to keep comments, and #1155, #1172 and #1180 build on that. Each of them now goes through the install plan, so --dry-run still writes nothing and a real install still writes exactly what the dry run showed: - _read_settings_for_merge and _write_merged_settings read, splice, back up and write through install_plan (the splice as bytes, so CRLF stays). - The steering hook is planned after the other hooks, and --no-steering removes it through the plan, so it sees what this run planned. - install_gitignore.apply reads, checks paths and writes through the plan. The block is planned after the instruction files and again without them when the question is answered no. - Gemini's hook scripts and the Cursor and Codex hook folders are no longer created before the plan is applied. - A hook settings file install will leave alone is named in the dry run. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

What was wrong
installcould wipe a user's hook settings file. All four hook writers read their file with strictjson.loads. One comment or trailing comma made that fail, the error was only logged,existingstayed{}, and the file was overwritten with nothing but our hooks. The backup line sat after the parse, so no.bakwas written either. This loses every setting in the file, not just the comments.Found while building #1155.
jsonc.pyalready protected MCP config, but the hook writers never used it.Files affected:
.claude/settings.json,.qoder/settings.json,.codebuddy/settings.json,.gemini/settings.json,~/.codex/hooks.json,~/.cursor/hooks.json.What changed
.bakfirst, and reinstalling leaves it byte-identical.installno longer says it installed those hooks. That covers broken JSON, a top level that is not an object,hooksor a hook event of the wrong shape, bad UTF-8, and a comment inside an event list we would have to replace. The refused Gemini and Cursor installs write no scripts either..baklike the others.jsonc.set_memberno longer moves a trailing comment onto a new member, and no longer drops a comment from an empty object. This also helps the MCP writer.One behaviour change to check
Installing over a corrupt hook file now refuses instead of overwriting it.
test_handles_corrupt_existing_jsonpinned the old overwrite for Cursor, so I rewrote it astest_leaves_corrupt_existing_json_alone. The MCP writer already refuses in this case. The cost is that someone with a truly broken file gets a message rather than hooks.Real checks
Real
code-review-graph installunder a throwawayHOME, on every JSON file each of the 16 platforms writes, seeded with a comment, a trailing comma, both, and broken JSON (80 cases). Staging was a snapshot oforigin/staging115dccf.On the 6 hook files in this PR (24 cases):
Real install, then real uninstall, on a commented
.claude/settings.json:After uninstall all three comments and both settings are still there, and our hooks are gone. The only leftover is a blank line where the hooks were.
Not run: the real clients reading these files. This checks what
installwrites, not how each client treats a commented file. Windows was not run here.Tests
tests/test_installer_ownership.pyrun against all six writers; all 75 failed on staging before the fix.tests/test_jsonc_removals.pyandtests/test_cli_install.py.🤖 Generated with Claude Code