Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/specify_cli/events/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2552,7 +2552,12 @@ def _safe_write_json(dst: Path, data: dict) -> None:
"""Write *data* as JSON to *dst* after validating the destination (#12)."""
_ensure_safe_destination(dst)
dst.parent.mkdir(parents=True, exist_ok=True)
dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
# A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape.
dst.write_text(
json.dumps(data, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8",
errors="backslashreplace",
)


def _ensure_safe_destination(dst: Path) -> None:
Expand Down
5 changes: 4 additions & 1 deletion src/specify_cli/integrations/copilot/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,9 @@ def _merge_vscode_settings(src: Path, dst: Path) -> None:
if not changed:
return

# A lone surrogate (\ud800) can't be UTF-8 encoded; write it back as its JSON escape.
dst.write_text(
json.dumps(existing, indent=4) + "\n", encoding="utf-8"
json.dumps(existing, indent=4, ensure_ascii=False) + "\n",
encoding="utf-8",
errors="backslashreplace",
)
31 changes: 31 additions & 0 deletions tests/integrations/test_integration_copilot.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,37 @@ def test_setup_merges_existing_vscode_settings(self, tmp_path):
assert settings not in created
assert not any("settings.json" in k for k in m.files)

def test_setup_merge_keeps_non_ascii_vscode_settings_readable(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir(parents=True)
existing = {"cSpell.words": ["naïve", "Привіт", "日本語"]}
settings = vscode_dir / "settings.json"
settings.write_text(json.dumps(existing, indent=4, ensure_ascii=False), encoding="utf-8")
m = IntegrationManifest("copilot", tmp_path)

copilot.setup(tmp_path, m, parsed_options={"commands": True})

text = settings.read_text(encoding="utf-8")
assert '"naïve"' in text and '"Привіт"' in text and '"日本語"' in text
assert json.loads(text)["cSpell.words"] == existing["cSpell.words"]

def test_setup_merge_keeps_escaped_lone_surrogate_in_vscode_settings(self, tmp_path):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
vscode_dir = tmp_path / ".vscode"
vscode_dir.mkdir(parents=True)
settings = vscode_dir / "settings.json"
settings.write_text('{"custom.setting": "\\ud800"}', encoding="utf-8")
m = IntegrationManifest("copilot", tmp_path)

copilot.setup(tmp_path, m, parsed_options={"commands": True})

data = json.loads(settings.read_text(encoding="utf-8"))
assert data["custom.setting"] == "\ud800"
assert "chat.promptFilesRecommendations" in data

def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
from specify_cli.integrations.copilot import CopilotIntegration
copilot = CopilotIntegration()
Expand Down
43 changes: 43 additions & 0 deletions tests/specify_cli/events/test_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -463,6 +463,49 @@ def test_remove_preserves_user_hooks(self, tmp_path):
assert len(data["hooks"]["PreToolUse"]) == 1
assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"

def test_merge_keeps_non_ascii_user_settings_readable(self, tmp_path):
integration = ClaudeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()

config_path = tmp_path / ".claude/settings.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text(
json.dumps({"env": {"GREETING": "Привіт — ¿qué tal? 日本語"}}, ensure_ascii=False),
encoding="utf-8",
)

events = {
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
}
install_integration_events(integration, tmp_path, manifest, events)

text = config_path.read_text(encoding="utf-8")
assert "Привіт — ¿qué tal? 日本語" in text
assert json.loads(text)["env"]["GREETING"] == "Привіт — ¿qué tal? 日本語"

def test_merge_keeps_escaped_lone_surrogate_in_user_settings(self, tmp_path):
integration = ClaudeIntegration()
manifest = MagicMock(spec=IntegrationManifest)
manifest.files = {}
manifest.record_file = MagicMock()
manifest.record_existing = MagicMock()

config_path = tmp_path / ".claude/settings.json"
config_path.parent.mkdir(parents=True, exist_ok=True)
config_path.write_text('{"env": {"ODD": "\\ud800"}}', encoding="utf-8")

events = {
"pre_tool_use": [{"command": "speckit.tdd.validate"}],
}
install_integration_events(integration, tmp_path, manifest, events)

data = json.loads(config_path.read_text(encoding="utf-8"))
assert data["env"]["ODD"] == "\ud800"
assert "PreToolUse" in data["hooks"]


# -- Copilot events JSON writing --------------------------------------------

Expand Down
Loading