fix(skills): preserve non-ASCII characters in skill frontmatter (#2917)

* fix(skills): preserve non-ASCII chars in skill frontmatter

Skill SKILL.md frontmatter descriptions containing non-ASCII
characters were escaped to \uXXXX / \xXX sequences because
yaml.safe_dump() was called without allow_unicode=True.

- Add allow_unicode=True to the 7 skill/command frontmatter
  safe_dump sites (extensions, presets, claude integration)
- Add regression tests for the render and extension-install paths

Follows the approach of #1936; encoding="utf-8" is already set on
the affected write paths, so no encoding change is needed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(_utils): add dump_frontmatter helper

Centralize skill/command frontmatter YAML serialization into a single
_utils.dump_frontmatter helper so no call site can drop allow_unicode or
diverge on formatting. Route the 7 existing sites through it and drop a
now-unused local yaml import.

Switch the extension test fixtures to yaml.safe_dump for parity with the
production safe-dump/safe-load codepaths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Seiya Kojima
2026-06-17 21:57:54 +09:00
committed by GitHub
parent de18d21b1c
commit 811a3aa447
6 changed files with 92 additions and 14 deletions

View File

@@ -66,6 +66,16 @@ class TestClaudeIntegration:
assert parsed["disable-model-invocation"] is False
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
def test_render_skill_unicode(self):
"""Test rendering a skill preserves non-ASCII characters."""
integration = get_integration("claude")
rendered = integration._render_skill(
"constitution",
{"description": "Prüfe Konformität der Implementierung"},
"Body",
)
assert "Prüfe Konformität" in rendered
def test_setup_upserts_context_section(self, tmp_path):
integration = get_integration("claude")
manifest = IntegrationManifest("claude", tmp_path)

View File

@@ -90,7 +90,7 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
yaml.safe_dump(manifest_data, f)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
@@ -119,6 +119,50 @@ def _create_extension_dir(temp_dir: Path, ext_id: str = "test-ext") -> Path:
return ext_dir
def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Path:
"""Create an extension whose command description contains non-ASCII characters."""
ext_dir = temp_dir / ext_id
ext_dir.mkdir()
description = "Prüfe Konformität der Implementierung"
manifest_data = {
"schema_version": "1.0",
"extension": {
"id": ext_id,
"name": "Unicode Extension",
"version": "1.0.0",
"description": description,
},
"requires": {"speckit_version": ">=0.1.0"},
"provides": {
"commands": [
{
"name": f"speckit.{ext_id}.hello",
"file": "commands/hello.md",
"description": description,
},
]
},
}
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
yaml.safe_dump(manifest_data, f, allow_unicode=True)
commands_dir = ext_dir / "commands"
commands_dir.mkdir()
(commands_dir / "hello.md").write_text(
"---\n"
f'description: "{description}"\n'
"---\n"
"\n"
"# Hello\n"
"\n"
"Body.\n",
encoding="utf-8",
)
return ext_dir
def _can_create_symlink(temp_dir: Path) -> bool:
"""Return True when the current platform/user can create file symlinks."""
target = temp_dir / "symlink-target.txt"
@@ -432,6 +476,18 @@ class TestExtensionSkillRegistration:
parsed = yaml.safe_load(skill_file.read_text(encoding="utf-8").split("---", 2)[1])
assert "argument-hint" not in parsed
def test_skill_md_unicode(self, skills_project, temp_dir):
"""SKILL.md generation should preserve non-ASCII characters."""
project_dir, skills_dir = skills_project
ext_dir = _create_unicode_extension_dir(temp_dir)
manager = ExtensionManager(project_dir)
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
skill_file = skills_dir / "speckit-uni-ext-hello" / "SKILL.md"
content = skill_file.read_text(encoding="utf-8")
assert "Prüfe Konformität" in content
def test_no_skills_when_ai_skills_disabled(self, no_skills_project, extension_dir):
"""No skills should be created when ai_skills is false."""
manager = ExtensionManager(no_skills_project)
@@ -692,7 +748,7 @@ class TestExtensionSkillRegistration:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "plan.md").write_text(
@@ -747,7 +803,7 @@ class TestExtensionSkillRegistration:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "exists.md").write_text(
@@ -1303,7 +1359,7 @@ class TestExtensionSkillEdgeCases:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
(ext_dir / "commands" / "plain.md").write_text(
@@ -1390,7 +1446,7 @@ class TestExtensionSkillEdgeCases:
},
}
with open(ext_dir / "extension.yml", "w") as f:
yaml.dump(manifest_data, f)
yaml.safe_dump(manifest_data, f)
(ext_dir / "commands").mkdir()
# Malformed YAML: invalid key-value syntax