mirror of
https://github.com/github/spec-kit.git
synced 2026-07-08 15:06:10 +08:00
feat(integrations): generalize post-processing to all format types (#3311)
* feat(integrations): add post_process_command_content() hook for all format types Add post_process_command_content(self, content: str) -> str to IntegrationBase with a no-op default. Wire it into register_commands() for non-skills format types (Markdown, TOML, YAML) after format rendering, before writing to disk. Also applies to aliases rendered via the inject_name path (cline, forge). Skills-format agents are excluded to preserve the existing post_process_skill_content() path and avoid double-processing. This gives extension authors a clean per-agent content transformation seam for all 21 non-skills integrations that previously had no post-processing hook. Ref: #3303 Assisted-By: 🤖 Claude Code * fix: initialize _integration before conditional branch Prevents potential UnboundLocalError if the non-skills guard is refactored without updating the alias path reference. Assisted-By: 🤖 Claude Code
This commit is contained in:
245
tests/test_post_process.py
Normal file
245
tests/test_post_process.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""Tests for post_process_command_content() hook on IntegrationBase.
|
||||
|
||||
Verifies that the generalized post-processing hook:
|
||||
- Runs for non-skills format types (Markdown, TOML, YAML)
|
||||
- Does NOT run for skills-format agents
|
||||
- Default no-op returns content unchanged
|
||||
- Exceptions propagate to caller
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def registrar():
|
||||
return CommandRegistrar()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ext_dir(tmp_path):
|
||||
"""Create a mock extension with a simple command template."""
|
||||
ext = tmp_path / "extension"
|
||||
ext.mkdir()
|
||||
cmd_dir = ext / "commands"
|
||||
cmd_dir.mkdir()
|
||||
return ext, cmd_dir
|
||||
|
||||
|
||||
def _write_cmd(cmd_dir, name="review.md", body="Review the code.\n"):
|
||||
cmd_file = cmd_dir / name
|
||||
cmd_file.write_text(
|
||||
f"---\ndescription: Test command\n---\n\n{body}",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return cmd_file
|
||||
|
||||
|
||||
class TestDefaultNoOp:
|
||||
def test_returns_content_unchanged(self):
|
||||
base = IntegrationBase()
|
||||
content = "Some command content\nwith multiple lines."
|
||||
assert base.post_process_command_content(content) == content
|
||||
|
||||
def test_empty_string(self):
|
||||
base = IntegrationBase()
|
||||
assert base.post_process_command_content("") == ""
|
||||
|
||||
|
||||
class TestMarkdownAgentPostProcess:
|
||||
def test_opencode_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
marker = "<!-- POST_PROCESSED -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".opencode" / "commands" / "speckit.test.review.md"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestTomlAgentPostProcess:
|
||||
def test_gemini_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
gemini = get_integration("gemini")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
gemini.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"gemini", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".gemini" / "commands" / "speckit.test.review.toml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestYamlAgentPostProcess:
|
||||
def test_goose_post_process_applied(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
goose = get_integration("goose")
|
||||
marker = "# POST_PROCESSED"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + f"\n{marker}\n"
|
||||
|
||||
monkeypatch.setattr(
|
||||
goose.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"goose", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
cmd_output = tmp_path / ".goose" / "recipes" / "speckit.test.review.yaml"
|
||||
assert cmd_output.exists()
|
||||
content = cmd_output.read_text(encoding="utf-8")
|
||||
assert marker in content
|
||||
|
||||
|
||||
class TestSkillsAgentExcluded:
|
||||
def test_claude_post_process_not_called(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
marker = "<!-- SHOULD_NOT_APPEAR -->"
|
||||
|
||||
def _inject_marker(self, content):
|
||||
return content + marker
|
||||
|
||||
monkeypatch.setattr(
|
||||
claude.__class__, "post_process_command_content", _inject_marker
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
skill_file = (
|
||||
tmp_path / ".claude" / "skills" / "speckit-test-review" / "SKILL.md"
|
||||
)
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert marker not in content
|
||||
|
||||
def test_skills_agent_method_never_called(
|
||||
self, tmp_path, registrar, ext_dir
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
claude = get_integration("claude")
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
|
||||
with patch.object(
|
||||
claude.__class__, "post_process_command_content", wraps=claude.post_process_command_content
|
||||
) as mock_method:
|
||||
registrar.register_commands(
|
||||
"claude", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
mock_method.assert_not_called()
|
||||
|
||||
|
||||
class TestExceptionPropagation:
|
||||
def test_hook_exception_propagates(
|
||||
self, tmp_path, registrar, ext_dir, monkeypatch
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
_write_cmd(cmd_dir)
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
opencode = get_integration("opencode")
|
||||
|
||||
def _raise(self, content):
|
||||
raise RuntimeError("Hook failed")
|
||||
|
||||
monkeypatch.setattr(
|
||||
opencode.__class__, "post_process_command_content", _raise
|
||||
)
|
||||
|
||||
commands = [{"name": "speckit.test.review", "file": "commands/review.md"}]
|
||||
with pytest.raises(RuntimeError, match="Hook failed"):
|
||||
registrar.register_commands(
|
||||
"opencode", commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
|
||||
class TestRegressionPlainTemplate:
|
||||
@pytest.mark.parametrize(
|
||||
"agent,path_pattern",
|
||||
[
|
||||
("claude", ".claude/skills/speckit-test-plain/SKILL.md"),
|
||||
("opencode", ".opencode/commands/speckit.test.plain.md"),
|
||||
],
|
||||
ids=["skills", "markdown"],
|
||||
)
|
||||
def test_plain_template_unchanged(
|
||||
self, tmp_path, registrar, ext_dir, agent, path_pattern
|
||||
):
|
||||
ext, cmd_dir = ext_dir
|
||||
body_text = "This is a plain command with no special content.\n"
|
||||
_write_cmd(cmd_dir, name="plain.md", body=body_text)
|
||||
|
||||
commands = [{"name": "speckit.test.plain", "file": "commands/plain.md"}]
|
||||
registrar.register_commands(
|
||||
agent, commands, "test-ext", ext, tmp_path
|
||||
)
|
||||
|
||||
output_file = tmp_path / path_pattern
|
||||
assert output_file.exists(), f"Output file missing for {agent}"
|
||||
content = output_file.read_text(encoding="utf-8")
|
||||
assert body_text.strip() in content, f"Body text missing in {agent} output"
|
||||
Reference in New Issue
Block a user