feat(cli): polite deep merge for settings.json and support JSONC#1863
feat(cli): polite deep merge for settings.json and support JSONC#1863RbBtSn0w wants to merge 5 commits intogithub:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
This PR updates Specify CLI’s .vscode/settings.json handling to avoid overwriting user settings during init/upgrade by parsing JSON-with-comments and performing a “polite” deep merge.
Changes:
- Added
strip_json_comments()and used it when reading template and existing VSCode settings. - Implemented
merge_json_files()with a deep-merge policy that preserves existing (user) values while adding new keys. - Added unit tests for comment stripping and merge fallback behavior; updated changelog entries.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds JSONC-ish parsing and polite deep-merge logic for .vscode/settings.json. |
tests/test_merge.py |
Introduces tests for comment stripping and merge behavior/fallbacks. |
CHANGELOG.md |
Documents the new merge/JSONC support behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| assert parsed["a"] == 1 | ||
| assert parsed["b"] == "value" | ||
| assert parsed["c"] == 3 | ||
|
|
src/specify_cli/__init__.py
Outdated
| if verbose: | ||
| console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object, using template instead[/yellow]") | ||
| return new_content | ||
|
|
||
| except (FileNotFoundError, json.JSONDecodeError) as e: | ||
| # If file doesn't exist or is invalid, just use new content | ||
| if verbose and not isinstance(e, FileNotFoundError): | ||
| console.print(f"[yellow]Warning: Could not parse existing JSON in {existing_path.name} ({e}), using template instead[/yellow]") |
src/specify_cli/__init__.py
Outdated
| if verbose: | ||
| console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object, using template instead[/yellow]") | ||
| return new_content | ||
|
|
||
| except (FileNotFoundError, json.JSONDecodeError) as e: | ||
| # If file doesn't exist or is invalid, just use new content | ||
| if verbose and not isinstance(e, FileNotFoundError): | ||
| console.print(f"[yellow]Warning: Could not parse existing JSON in {existing_path.name} ({e}), using template instead[/yellow]") |
| import re | ||
| # Remove single-line comments | ||
| content = re.sub(r'//.*$', '', content, flags=re.MULTILINE) | ||
| # Remove multi-line comments | ||
| content = re.sub(r'/\*.*?\*/', '', content, flags=re.DOTALL) | ||
| return content | ||
|
|
| - feat(cli): support for JSONC (JSON with comments) in VSCode settings.json merging | ||
| - feat(cli): polite deep merge for JSON files that preserves existing user settings | ||
| - feat(cli): basic validation for JSON object structures during initialization | ||
| - feat(cli): strip_json_comments utility for handling VSCode configuration files |
…ommas, fix indentation
|
I've addressed the feedback regarding JSONC parsing:
|
There was a problem hiding this comment.
Pull request overview
This PR updates Specify CLI’s VSCode .vscode/settings.json handling to avoid overwriting user settings by adding JSONC parsing support and a “polite” deep-merge strategy during init/upgrade flows.
Changes:
- Added
strip_json_comments()to support VSCode-style JSONC (line and block comments) and attempted trailing-comma handling. - Implemented “polite” deep merge logic in
merge_json_files()to preserve existing user values while adding missing recommended keys. - Added unit tests for comment stripping and merge behavior; updated
CHANGELOG.md.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds JSONC stripping + polite deep merge, and uses it when handling .vscode/settings.json. |
tests/test_merge.py |
New tests for JSONC stripping and merge behavior (including invalid/non-object existing JSON). |
CHANGELOG.md |
Documents the new JSONC + polite merge behavior in Unreleased notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/specify_cli/__init__.py
Outdated
|
|
||
| def deep_merge(base: dict, update: dict) -> dict: | ||
| """Recursively merge update dict into base dict.""" | ||
| if not isinstance(new_content, dict): |
tests/test_merge.py
Outdated
| def test_strip_json_comments_basic(): | ||
| jsonc = """ | ||
| { | ||
| // Single line comment | ||
| "a": 1, | ||
| "b": "value", /* Multi-line | ||
| comment */ | ||
| "c": 3 // Another one | ||
| } | ||
| """ | ||
| stripped = strip_json_comments(jsonc) | ||
| parsed = json.loads(stripped) | ||
| assert parsed["a"] == 1 | ||
| assert parsed["b"] == "value" | ||
| assert parsed["c"] == 3 | ||
|
|
||
| def test_strip_json_comments_with_urls(): | ||
| # URL in string should NOT be stripped | ||
| jsonc = """ | ||
| { | ||
| "url": "https://github.com/spec-kit", | ||
| "path": "//local/path", | ||
| "commented": 1 // This is a comment | ||
| } | ||
| """ | ||
| stripped = strip_json_comments(jsonc) | ||
| parsed = json.loads(stripped) | ||
| assert parsed["url"] == "https://github.com/spec-kit" | ||
| assert parsed["path"] == "//local/path" | ||
| assert parsed["commented"] == 1 | ||
|
|
||
| def test_strip_json_comments_trailing_commas(): | ||
| # Trailing commas in objects and arrays | ||
| jsonc = """ | ||
| { | ||
| "array": [1, 2, 3, ], | ||
| "obj": { | ||
| "a": 1, | ||
| }, | ||
| } | ||
| """ | ||
| stripped = strip_json_comments(jsonc) | ||
| parsed = json.loads(stripped) | ||
| assert parsed["array"] == [1, 2, 3] | ||
| assert parsed["obj"]["a"] == 1 |
tests/test_merge.py
Outdated
| @@ -0,0 +1,98 @@ | |||
| import json | |||
| from pathlib import Path | |||
| import pytest | |||
src/specify_cli/__init__.py
Outdated
| if char in ('"', "'") and (i == 0 or content[i-1] != '\\'): | ||
| if not in_string: | ||
| in_string = True | ||
| string_char = char | ||
| elif char == string_char: | ||
| in_string = False |
src/specify_cli/__init__.py
Outdated
| # Handle trailing commas: find a comma followed by closing brace/bracket | ||
| # This regex handles cases like [1, 2, ] or {"a": 1, } | ||
| clean_content = re.sub(r',\s*([\]}])', r'\1', clean_content) | ||
|
|
…prove merge robustness
|
Expanded test coverage across multiple dimensions:
|
…C/merge validation
|
Addressing final review comments:
|
There was a problem hiding this comment.
Pull request overview
This PR updates Specify CLI’s VSCode initialization/upgrade flow to avoid overwriting a user’s existing .vscode/settings.json by adding JSONC parsing (comments/trailing commas) and performing a “polite” deep merge that preserves existing user values.
Changes:
- Add
strip_json_comments()to parse VSCode-style JSONC (comments + trailing commas). - Update settings handling to merge template settings into existing settings without overwriting user values (recursive for dicts).
- Add unit tests for JSONC stripping + merge behavior and document the feature in the changelog.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 5 comments.
| File | Description |
|---|---|
src/specify_cli/__init__.py |
Adds JSONC stripping + polite deep merge and uses it during .vscode/settings.json handling. |
tests/test_merge.py |
Adds tests for comment stripping, deep merge behavior, BOM handling, and error cases. |
CHANGELOG.md |
Notes JSONC support and polite merge behavior. |
Comments suppressed due to low confidence (1)
src/specify_cli/init.py:673
- Merging
.vscode/settings.jsonrewrites the file viajson.dump(...), which will drop any existing comments and original formatting even though JSONC parsing is now supported. If preserving comments is a goal, you’ll need a non-destructive update strategy (e.g., patching the text AST) or at least document/warn that comments will be removed on merge.
if dest_file.exists():
merged = merge_json_files(dest_file, new_settings, verbose=verbose and not tracker)
with open(dest_file, 'w', encoding='utf-8') as f:
json.dump(merged, f, indent=4)
f.write('\n')
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
tests/test_merge.py
Outdated
| """If for some reason new_content is not a dict, return empty dict as safety.""" | ||
| existing_file = tmp_path / "ok.json" | ||
| existing_file.write_text('{"a": 1}') | ||
|
|
||
| # In the updated implementation, if new_content is not a dict, we return {} | ||
| assert merge_json_files(existing_file, ["not", "a", "dict"]) == {} |
src/specify_cli/__init__.py
Outdated
|
|
||
| This implementation handles VSCode settings.json files by: | ||
| 1. Skipping comments inside "string literals" | ||
| 2. Correctlly handling escaped quotes by counting backslashes |
src/specify_cli/__init__.py
Outdated
| clean_content = "".join(result) | ||
|
|
||
| # Handle trailing commas: find a comma followed by closing brace/bracket | ||
| # This is now safer as we've already stripped comments and identified strings | ||
| # However, to be 100% safe against commas in strings, we should have handled this | ||
| # during the character loop. Let's refine the regex to be very specific to | ||
| # JSON structure (comma followed by whitespace and closing char). | ||
| # Since we've already joined the result, we can apply a final cleanup. | ||
| # To truly avoid commas in strings, we'd need to mark string ranges. | ||
| # For now, this regex is standard for basic JSONC to JSON conversion. | ||
| clean_content = re.sub(r',\s*([\]}])', r'\1', clean_content) | ||
|
|
src/specify_cli/__init__.py
Outdated
| if verbose: | ||
| console.print(f"[yellow]Warning: Template content for {existing_path.name} is not a dictionary. Skipping merge.[/yellow]") | ||
| return {} | ||
|
|
src/specify_cli/__init__.py
Outdated
| console.print(f"[yellow]Warning: Existing JSON in {existing_path.name} is not an object, using template instead[/yellow]") | ||
| return new_content |
|
Final updates to address all Copilot and Maintainer concerns:
|
Description
This PR addresses issue #1799, where the Specify CLI would overwrite the user's existing
.vscode/settings.jsonfile during initialization or upgrades, leading to data loss of personal preferences.Key Changes
strip_json_commentsutility to handle JSON files with single-line (//) and multi-line (/* */) comments, which are common in VSCode configuration files.deep_merge_politewhich ensures that:tests/test_merge.pycovering comment stripping, polite merging logic, and error handling for invalid/non-dict JSON files.CHANGELOG.mdwith these improvements.Verification
settings.jsonno longer cause the merge to fail and revert to a full overwrite.editor.fontSize) are kept intact.uv run pytest tests/test_merge.py.Fixes #1799