"""Tests for events module: integration runtime events.""" from __future__ import annotations import json import os import platform import shlex from pathlib import Path, PurePath from unittest.mock import MagicMock, patch import pytest from specify_cli.events import ( CANONICAL_EVENTS, EVENTS_DISPATCHER_REL, collect_extension_events, install_integration_events, remove_integration_events, resolve_events, validate_events, resolve_and_run_event_command, ) from specify_cli.integrations.manifest import IntegrationManifest from specify_cli.integrations.claude import ClaudeIntegration from specify_cli.integrations.cursor_agent import CursorAgentIntegration from specify_cli.integrations.opencode import OpencodeIntegration from specify_cli.integrations.copilot import CopilotIntegration # -- resolve_events -------------------------------------------------------- class TestResolveEvents: """Test the 4-layer event resolution chain.""" def test_layer1_disabled_returns_empty(self, tmp_path): """--events false returns empty dict.""" result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, {"events": "false"}, ) assert result == {} def test_layer4_built_in_defaults(self, tmp_path): """Returns baseline defaults when no overrides exist.""" result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) assert result == {"post_tool_use": [{"command": "speckit.tdd.validate"}]} def test_layer3_extension_events_appended(self, tmp_path): """Extension-declared events are resolved and appended.""" ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) ext_yml = ext_dir / "extension.yml" ext_yml.write_text( "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) assert "post_tool_use" in result assert "session_start" in result assert result["session_start"] == [{"command": "speckit.my-ext.boot"}] def test_layer3_multiple_extensions_same_event_accumulate(self, tmp_path): """Two extensions declaring the same event both run (#2).""" for ext_id, cmd in (("my-ext", "speckit.my-ext.boot"), ("other-ext", "speckit.other.boot")): ext_dir = tmp_path / ".specify" / "extensions" / ext_id ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( f"events:\n session_start:\n command: {cmd}\n", encoding="utf-8", ) result = resolve_events("claude", None, tmp_path, None) assert result["session_start"] == [ {"command": "speckit.my-ext.boot"}, {"command": "speckit.other.boot"}, ] def test_layer2_yaml_override_replaces(self, tmp_path): """integration-events.yml override replaces baseline entirely.""" override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) override_file.write_text( "integrations:\n" " claude:\n" " events:\n" " stop:\n" " command: speckit.override.stop\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) assert result == {"stop": [{"command": "speckit.override.stop"}]} def test_layer2_empty_events_disables(self, tmp_path): """Empty events override disables events.""" override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) override_file.write_text( "integrations:\n" " claude:\n" " events: {}\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) assert result == {} def test_no_config_no_events(self, tmp_path): """Safe fallback with empty config/options.""" result = resolve_events("claude", None, tmp_path, None) assert result == {} # -- collect_extension_events ----------------------------------------------- class TestCollectExtensionEvents: """Test scanning extension.yml files for events: declarations.""" def test_no_extensions_dir(self, tmp_path): assert collect_extension_events(tmp_path) == {} def test_no_events_in_extension(self, tmp_path): ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text("extension:\n id: my-ext\n", encoding="utf-8") assert collect_extension_events(tmp_path) == {} def test_events_collected(self, tmp_path): ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "events:\n pre_tool_use:\n command: speckit.my-ext.check\n", encoding="utf-8", ) result = collect_extension_events(tmp_path) assert result == {"pre_tool_use": [{"command": "speckit.my-ext.check"}]} def test_invalid_yaml_skipped(self, tmp_path): ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8") assert collect_extension_events(tmp_path) == {} def test_event_command_ref_canonicalized_via_manifest(self, tmp_path): """R1: events are read from a validated ExtensionManifest, so an obsolete command ref (e.g. my-ext.boot) is canonicalized (speckit.my-ext.boot) the same way hook refs are at install.""" from specify_cli.extensions import ExtensionRegistry import yaml as _yaml ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) # Manifest declares an alias-form event command ref (my-ext.boot) # alongside the command it resolves to; the validated manifest lifts # the ref to speckit.my-ext.boot (C11). (ext_dir / "extension.yml").write_text( _yaml.dump({ "schema_version": "1.0", "extension": { "id": "my-ext", "name": "My Ext", "version": "1.0.0", "description": "test", }, "requires": {"speckit_version": ">=0.1"}, "provides": { "commands": [ {"name": "speckit.my-ext.boot", "file": "commands/boot.md"} ] }, "events": {"session_start": {"command": "my-ext.boot"}}, }), encoding="utf-8", ) ExtensionRegistry(tmp_path / ".specify" / "extensions").add( "my-ext", {"enabled": True} ) result = collect_extension_events(tmp_path) # The ref was canonicalized to speckit.my-ext.boot by the validated # manifest, so dispatch can match it (raw-YAML reading would have # emitted the obsolete my-ext.boot and the hook would no-op). assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]} # -- Class-driven mappings -------------------------------------------------- class TestCanonicalEventMapping: """Verify registry-driven mapping is correct on integration classes.""" def test_claude_identity(self): integration = ClaudeIntegration() assert integration.supports_events() is True assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "PreToolUse" assert integration.CANONICAL_TO_NATIVE["session_start"] == "SessionStart" def test_cursor_camelcase(self): integration = CursorAgentIntegration() assert integration.supports_events() is True assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "preToolUse" assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "beforeSubmitPrompt" def test_opencode_limited(self): integration = OpencodeIntegration() assert integration.supports_events() is True assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before" assert "stop" not in integration.CANONICAL_TO_NATIVE def test_copilot_mapping(self): integration = CopilotIntegration() assert integration.supports_events() is True assert integration.CANONICAL_TO_NATIVE["session_start"] == "sessionStart" assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted" def test_gemini_mapping_includes_before_agent(self): # S6: Gemini exposes BeforeAgent for user_prompt_submit and AfterAgent # for stop (verified against Gemini CLI's hooks docs). from specify_cli.integrations.gemini import GeminiIntegration integration = GeminiIntegration() assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "BeforeTool" assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent" assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent" def test_tabnine_mapping_includes_before_agent(self): # S7: Tabnine's Gemini-compatible schema provides BeforeAgent/AfterAgent. from specify_cli.integrations.tabnine import TabnineIntegration integration = TabnineIntegration() assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent" assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent" # -- Event-capable adapters declare --events (#8, #9) ------------------------ class TestEventCapableOptionsComposition: """Event-capable integrations must declare --events so the documented --events false opt-out is accepted.""" def _has_option(self, opts, name): return any(o.name == name for o in opts) def test_copilot_declares_events(self): # #9: CopilotIntegration.options() composed with super() so --events # is declared alongside --skills. opts = CopilotIntegration().options() assert self._has_option(opts, "--skills") assert self._has_option(opts, "--events"), ( "Copilot is event-capable but --events is not declared; " "--integration-options \"--events false\" would be rejected." ) def test_devin_declares_events(self): # #8: DevinIntegration.options() composed with super() so --events # is declared alongside --skills. from specify_cli.integrations.devin import DevinIntegration opts = DevinIntegration().options() assert self._has_option(opts, "--skills") assert self._has_option(opts, "--events"), ( "Devin is event-capable but --events is not declared; " "--integration-options \"--events false\" would be rejected." ) def test_cursor_declares_events(self): # Cursor already composed correctly; assert it stays that way. opts = CursorAgentIntegration().options() assert self._has_option(opts, "--skills") assert self._has_option(opts, "--events") def test_codex_declares_events(self): # Codex already composed correctly; assert it stays that way. from specify_cli.integrations.codex import CodexIntegration opts = CodexIntegration().options() assert self._has_option(opts, "--skills") assert self._has_option(opts, "--events") # -- validate_events -------------------------------------------------------- class TestValidateEvents: """Test manifest validation.""" def test_unknown_event_rejected(self): from specify_cli.extensions import ValidationError data = {"events": {"unknown_event": {"command": "speckit.tdd.validate"}}} with pytest.raises(ValidationError) as exc: validate_events(data) assert "Unknown event" in str(exc.value) def test_known_event_accepted(self): data = {"events": {"pre_tool_use": {"command": "speckit.tdd.validate"}}} validate_events(data) # no raise def test_all_canonical_events_accepted(self): data = { "events": { name: {"command": "speckit.test"} for name in CANONICAL_EVENTS } } validate_events(data) # no raise # -- Claude settings JSON merging ------------------------------------------- class TestClaudeJsonMerging: """Test Claude settings JSON merging and cleanup.""" def test_merge_into_empty_file(self, tmp_path): integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}], } install_integration_events(integration, tmp_path, manifest, events) config_path = tmp_path / ".claude/settings.json" assert config_path.is_file() data = json.loads(config_path.read_text()) assert "hooks" in data assert "PreToolUse" in data["hooks"] assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write" # #6: native schema is a single `command` string, not command+args. inner = data["hooks"]["PreToolUse"][0]["hooks"][0] assert isinstance(inner["command"], str) assert "args" not in inner assert "speckit.tdd.validate" in inner["command"] assert "pre_tool_use" in inner["command"] # The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for # Claude, and double-quoted so a project path with spaces doesn't # word-split (C2) while the variable still expands. assert "${CLAUDE_PROJECT_DIR}/" in inner["command"] assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in inner["command"] def test_claude_emits_all_handlers_for_same_event(self, tmp_path): """#2: two handlers on the same event both appear in the native config.""" integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [ {"command": "speckit.tdd.validate"}, {"command": "speckit.other.check"}, ], } install_integration_events(integration, tmp_path, manifest, events) data = json.loads((tmp_path / ".claude/settings.json").read_text()) inner_hooks = data["hooks"]["PreToolUse"][0]["hooks"] commands = [h["command"] for h in inner_hooks] assert any("speckit.tdd.validate" in c for c in commands) assert any("speckit.other.check" in c for c in commands) def test_remove_preserves_user_hooks(self, tmp_path): integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() # Pre-seed user setting config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text( json.dumps( { "hooks": { "PreToolUse": [ { "matcher": "Bash", "hooks": [ { "type": "command", "command": "user-check", } ], } ] } } ) ) events = { "pre_tool_use": [{"command": "speckit.tdd.validate"}], } install_integration_events(integration, tmp_path, manifest, events) remove_integration_events(integration, tmp_path, manifest) data = json.loads(config_path.read_text()) assert "hooks" in data assert "PreToolUse" in data["hooks"] assert len(data["hooks"]["PreToolUse"]) == 1 assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" # -- Copilot events JSON writing -------------------------------------------- class TestCopilotJsonWriting: """Test Copilot dedicated .github/hooks/speckit.json generation.""" def test_copilot_json_generation(self, tmp_path): integration = CopilotIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "session_start": [{"command": "speckit.agent-context.update", "timeout": 60}], } install_integration_events(integration, tmp_path, manifest, events) config_path = tmp_path / ".github/hooks/speckit.json" assert config_path.is_file() data = json.loads(config_path.read_text()) assert data["version"] == 1 assert "hooks" in data assert "sessionStart" in data["hooks"] entry = data["hooks"]["sessionStart"][0] assert entry["type"] == "command" # #6: a complete shell command string (not command+args). assert "speckit.agent-context.update" in entry["bash"] assert "session_start" in entry["bash"] # S4: bash and powershell get independent OS-targeted interpreters so # a config generated on one OS works on the other. R2: command/event # args are shell-quoted for each target shell. assert "speckit.agent-context.update" in entry["powershell"] assert entry["bash"] != entry["powershell"] # bash uses POSIX interpreter python3 (shlex.quote leaves safe tokens # bare); powershell uses python single-quoted with the & call operator # so the quoted command is actually invoked (C1). assert entry["bash"].startswith("python3 ") assert entry["powershell"].startswith("& 'python' ") # PowerShell always single-quotes; POSIX leaves metacharacter-free # identifiers bare (shlex.quote only quotes when needed). assert "'speckit.agent-context.update'" in entry["powershell"] assert "speckit.agent-context.update" in entry["bash"] # R2: native timeout gets the buffer (60 + 5 = 65) so the agent's # outer cap fires after the dispatcher's inner subprocess timeout. assert entry["timeoutSec"] == 65 # -- Cursor hooks.json version + matcher grouping (#7, S3) ------------------- class TestCursorJsonWriting: """#7: .cursor/hooks.json requires top-level version:1; S3: matcher grouping.""" def test_cursor_json_includes_version(self, tmp_path): integration = CursorAgentIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) data = json.loads((tmp_path / ".cursor/hooks.json").read_text()) assert data["version"] == 1 assert "sessionStart" in data["hooks"] def test_cursor_json_preserves_user_version(self, tmp_path): integration = CursorAgentIntegration() config_path = tmp_path / ".cursor/hooks.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({"version": 1, "hooks": {}})) manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) data = json.loads(config_path.read_text()) assert data["version"] == 1 def test_nested_matcher_grouping_per_distinct_matcher(self, tmp_path): """S3: two handlers with different matchers produce two matcher-groups.""" integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [ {"command": "speckit.first", "matcher": "Edit"}, {"command": "speckit.second", "matcher": "Bash"}, ], } install_integration_events(integration, tmp_path, manifest, events) data = json.loads((tmp_path / ".claude/settings.json").read_text()) groups = data["hooks"]["PreToolUse"] matchers = sorted(g["matcher"] for g in groups) assert matchers == ["Bash", "Edit"] # Each group holds exactly its own handler. by_matcher = {g["matcher"]: g["hooks"] for g in groups} assert len(by_matcher["Edit"]) == 1 assert "speckit.first" in by_matcher["Edit"][0]["command"] assert len(by_matcher["Bash"]) == 1 assert "speckit.second" in by_matcher["Bash"][0]["command"] def test_nested_shared_matcher_stays_one_group(self, tmp_path): """S3: handlers sharing a matcher stay in a single matcher-group.""" integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [ {"command": "speckit.first", "matcher": "Edit"}, {"command": "speckit.second", "matcher": "Edit"}, ], } install_integration_events(integration, tmp_path, manifest, events) data = json.loads((tmp_path / ".claude/settings.json").read_text()) groups = data["hooks"]["PreToolUse"] assert len(groups) == 1 assert groups[0]["matcher"] == "Edit" assert len(groups[0]["hooks"]) == 2 # -- Gemini timeout unit (#7) ------------------------------------------------ class TestGeminiTimeoutUnit: """Gemini measures hook timeouts in milliseconds, not seconds.""" def test_gemini_timeout_converted_to_ms(self, tmp_path): from specify_cli.integrations.gemini import GeminiIntegration from specify_cli.events import _native_timeout integration = GeminiIntegration() # 60 (seconds) -> 60000 (ms) for Gemini; unchanged for seconds-based agents. assert _native_timeout(integration, 60) == 60000 assert _native_timeout(ClaudeIntegration(), 60) == 60 def test_tabnine_timeout_converted_to_ms(self): """R5: Tabnine mirrors Gemini's ms-based hook schema.""" from specify_cli.integrations.tabnine import TabnineIntegration from specify_cli.events import _native_timeout assert _native_timeout(TabnineIntegration(), 60) == 60000 def test_qwen_timeout_converted_to_ms(self): """U1: Qwen Code command hooks use milliseconds (default 60000).""" from specify_cli.integrations.qwen import QwenIntegration from specify_cli.events import _native_timeout assert _native_timeout(QwenIntegration(), 60) == 60000 # -- Devin root-nested format (U2) + Copilot agentStop (U3) ------------------ class TestDevinRootNestedFormat: """U2: Devin's hooks.v1.json is a root event map with no 'hooks' wrapper.""" def test_devin_events_written_at_root(self, tmp_path): from specify_cli.integrations.devin import DevinIntegration integration = DevinIntegration() assert integration.events_format == "json-root-nested" manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) data = json.loads((tmp_path / ".devin/hooks.v1.json").read_text()) # Event keys are top-level (no "hooks" wrapper). assert "PreToolUse" in data assert "hooks" not in data def test_devin_teardown_removes_owned_and_preserves_user(self, tmp_path): from specify_cli.integrations.devin import DevinIntegration integration = DevinIntegration() config_path = tmp_path / ".devin/hooks.v1.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({ "PreToolUse": [{ "matcher": "exec", "hooks": [{"type": "command", "command": "user-check"}], }] })) manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() install_integration_events( integration, tmp_path, manifest, {"stop": [{"command": "speckit.end"}]}, ) remove_integration_events(integration, tmp_path, manifest) data = json.loads(config_path.read_text()) # User hook preserved at the root; Specify's Stop gone. assert "Stop" not in data assert data["PreToolUse"][0]["matcher"] == "exec" class TestCopilotAgentStop: """U3: Copilot maps the canonical stop lifecycle to native agentStop.""" def test_copilot_stop_mapping(self): integration = CopilotIntegration() assert integration.CANONICAL_TO_NATIVE.get("stop") == "agentStop" # -- Shell quoting & matcher escaping (R2, R4) ------------------------------- class TestDispatcherCommandQuoting: """R2: dispatcher command components are shell-quoted so spaces and shell metacharacters are passed as single arguments, not reinterpreted.""" def test_command_metacharacters_are_quoted_posix(self, tmp_path): from specify_cli.events import _dispatcher_command cmd = _dispatcher_command( ClaudeIntegration(), tmp_path, "speckit.x; rm -rf /", "pre_tool_use", target_os="posix", ) # The metacharacter-bearing command is single-quoted as one argument. assert "'speckit.x; rm -rf /'" in cmd def test_interpreter_with_space_is_quoted_posix(self, tmp_path): import shlex from specify_cli.events import _dispatcher_command # Simulate a venv interpreter under a path with spaces. venv = tmp_path / ".venv" / "bin" / "python" venv.parent.mkdir(parents=True) venv.write_text("#!/bin/sh\n") proj = tmp_path cmd = _dispatcher_command( ClaudeIntegration(), proj, "speckit.x.y", "stop", target_os="host", ) # The command must tokenize back into interpreter + dispatcher + 2 args. tokens = shlex.split(cmd) # dispatcher token carries the ${CLAUDE_PROJECT_DIR} prefix (double- # quoted in the raw string, but shlex.split strips the quotes). assert any("events.py" in t for t in tokens) assert "speckit.x.y" in tokens assert "stop" in tokens def test_windows_target_uses_powershell_quoting(self, tmp_path): from specify_cli.events import _dispatcher_command cmd = _dispatcher_command( CopilotIntegration(), tmp_path, "speckit.x.y", "session_start", target_os="windows", ) # PowerShell single-quoted literals, and the & call operator so the # quoted interpreter is actually invoked (C1). assert cmd.startswith("& ") assert "'speckit.x.y'" in cmd assert "'session_start'" in cmd def test_host_target_never_emits_powershell_quotes(self, tmp_path): """C1: the host target uses POSIX quoting on every platform so a single-command-string hook (Claude/Gemini/etc.) stays invocable — never 'python' (which PowerShell wouldn't invoke without &).""" from specify_cli.events import _shell_quote # Safe tokens pass through bare under host (POSIX), not PS-quoted. assert _shell_quote("python3", "host") == "python3" assert _shell_quote("speckit.x.y", "host") == "speckit.x.y" def test_claude_dispatcher_double_quoted_for_spaces(self, tmp_path): """C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is double-quoted so a project path containing spaces doesn't word-split.""" from specify_cli.events import _dispatcher_command cmd = _dispatcher_command( ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", target_os="host", ) assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in cmd class TestTomlMatcherEscaping: """R4: the TOML matcher is escaped like command, not raw-interpolated.""" def test_matcher_with_quote_stays_valid_toml(self, tmp_path): from specify_cli.integrations.codex import CodexIntegration integration = CodexIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() # A matcher containing a double quote would break a raw TOML basic # string; it must be escaped. install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.x.y", "matcher": 'Ba"sh'}]}, ) content = (tmp_path / ".codex" / "config.toml").read_text() # Round-trips through a TOML parser without error. try: import tomllib parsed = tomllib.loads(content) except ModuleNotFoundError: import tomli as tomllib # type: ignore parsed = tomllib.loads(content) # The matcher value survived intact. group = parsed["hooks"]["PreToolUse"][0] assert group["matcher"] == 'Ba"sh' # -- Opencode TS Plugin merging --------------------------------------------- class TestOpencodePluginMerging: """Test Opencode typescript plugin generation.""" def test_opencode_ts_plugin_generation(self, tmp_path): integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}], "session_start": [{"command": "speckit.agent-context.update"}], } install_integration_events(integration, tmp_path, manifest, events) plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts" assert plugin_path.is_file() content = plugin_path.read_text() assert "runEvent" in content assert "tool.execute.before" in content assert "session.created" in content assert "speckit.tdd.validate" in content assert "speckit.agent-context.update" in content # #13: failures must propagate via throw, not process.exit(2) which # would kill the OpenCode host process. assert "process.exit(2)" not in content assert "throw new Error" in content def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path): """C8/C9: the dispatcher + interpreter are resolved per-project at plugin load from the `directory` OpenCode passes (not process.cwd()), preferring a project venv, and the dispatcher is launched via execFileSync (argv, no shell).""" integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() # Create a project venv so the plugin's runtime resolver prefers it. venv_bin = tmp_path / ".venv" / "bin" / "python" venv_bin.parent.mkdir(parents=True) venv_bin.write_text("#!/bin/sh\n") events = {"session_start": [{"command": "speckit.boot"}]} install_integration_events(integration, tmp_path, manifest, events) content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() # Runtime venv-interpreter preference is baked into the resolver. assert ".venv" in content and "python" in content # Dispatcher is resolved from `directory`, not process.cwd() (C8). assert "path.join(process.cwd()" not in content assert "directory" in content # execFileSync (argv, no shell) instead of a shell command string (C9). assert "execFileSync" in content assert "execSync(`" not in content # R2: venv interpreter is probed for specify_cli importability before # selection (an unrelated project venv shouldn't shadow the fallback). assert "canImportSpecifyCli" in content # S2: the PATH fallback is python on Windows (python3 is commonly # absent there), python3 on POSIX. assert "process.platform === 'win32'" in content assert "'python'" in content assert "'python3'" in content def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path): """#2: multiple handlers on the same native event all invoke runEvent.""" integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "session_start": [ {"command": "speckit.first.boot"}, {"command": "speckit.second.boot"}, ], } install_integration_events(integration, tmp_path, manifest, events) content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() assert "speckit.first.boot" in content assert "speckit.second.boot" in content # Suppressed #6: each handler call is wrapped in try/catch and errors aggregated. assert "try {" in content assert "errors.push(" in content assert "throw new Error(errors.join" in content def test_opencode_ts_plugin_forwards_output(self, tmp_path): """C7: tool callbacks forward both input and output to runEvent so pre_tool_use can inspect tool args and post_tool_use the result.""" integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() events = { "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}], "post_tool_use": [{"command": "speckit.tdd.after"}], } install_integration_events(integration, tmp_path, manifest, events) content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() # runEvent signature carries both input and output. S1: command/event # are JSON string literals (double-quoted, escaped). S3: the per- # handler timeout (seconds) is threaded as the 5th arg. assert 'runEvent("speckit.tdd.validate", "pre_tool_use", input, output, 60)' in content assert 'runEvent("speckit.tdd.after", "post_tool_use", input, output, 60)' in content # Tool callbacks pass both arguments through. assert "_pre_tool_use(input, output)" in content assert "_post_tool_use(input, output)" in content def test_opencode_ts_plugin_escapes_metacharacters(self, tmp_path): """S1: command/matcher values with quotes/backticks are serialized as JSON string literals so they can't break the generated TypeScript or inject code.""" integration = OpencodeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() # A command and matcher containing characters that would break a # single-quoted TS literal. events = { "pre_tool_use": [{"command": "speckit.x'y`code", "matcher": "Ed'it"}], } install_integration_events(integration, tmp_path, manifest, events) content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text() # The value must appear inside a JSON double-quoted literal, not a # single-quoted TS literal (which a quote/backtick would break). assert json.dumps("speckit.x'y`code") in content # The dangerous single-quoted form (runEvent('speckit.x'y...')) — # where the embedded quote would terminate the literal — is absent. assert "runEvent('speckit.x" not in content assert json.dumps("ed'it") in content # -- Command runner test (core execution) ----------------------------------- class TestCommandRunner: """Test the core command/script resolution and runner.""" def test_run_command_not_found(self, tmp_path): code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path) assert code == 0 # no-ops gracefully def test_extension_command_resolves_when_file_stem_differs(self, tmp_path): """S8: an extension command whose declared file differs from its command name resolves via the manifest, not a file-stem scan.""" from specify_cli.events import _find_command_template from specify_cli.extensions import ExtensionRegistry ext_id = "selftest" ext_dir = tmp_path / ".specify" / "extensions" / ext_id cmds_dir = ext_dir / "commands" cmds_dir.mkdir(parents=True) # Command name is speckit.selftest.extension but the file is selftest.md. (ext_dir / "extension.yml").write_text( "schema_version: '1.0'\n" "extension:\n" " id: selftest\n" " name: Selftest\n" " version: 1.0.0\n" " description: test\n" "requires:\n" " speckit_version: '>=0.1'\n" "provides:\n" " commands:\n" " - name: speckit.selftest.extension\n" " file: commands/selftest.md\n", encoding="utf-8", ) (cmds_dir / "selftest.md").write_text( "---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8" ) ExtensionRegistry(tmp_path / ".specify" / "extensions").add( ext_id, {"enabled": True} ) template, resolved_ext = _find_command_template( "speckit.selftest.extension", tmp_path ) assert template is not None assert template.name == "selftest.md" assert resolved_ext == ext_id def test_disabled_extension_command_not_resolved(self, tmp_path): """S1: a disabled extension's command is skipped by _find_command_template (both the manifest loop and the disk-fallback scan), so a stale hook can't execute a disabled extension.""" from specify_cli.events import _find_command_template from specify_cli.extensions import ExtensionRegistry import yaml as _yaml # Manifest-resolvable path (step 1). ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" cmds_dir = ext_dir / "commands" cmds_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( _yaml.dump({ "schema_version": "1.0", "extension": {"id": "my-ext", "name": "My Ext", "version": "1.0.0", "description": "test"}, "requires": {"speckit_version": ">=0.1"}, "provides": {"commands": [{"name": "speckit.my-ext.boot", "file": "commands/boot.md"}]}, "events": {"session_start": {"command": "speckit.my-ext.boot"}}, }), encoding="utf-8", ) (cmds_dir / "boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8") ExtensionRegistry(tmp_path / ".specify" / "extensions").add( "my-ext", {"enabled": False} ) template, _ = _find_command_template("speckit.my-ext.boot", tmp_path) assert template is None, "Disabled extension's command was resolved (manifest loop)." # Disk-fallback path (step 2): stem == command name. (cmds_dir / "speckit.my-ext.boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8") template, _ = _find_command_template("speckit.my-ext.boot", tmp_path) assert template is None, "Disabled extension's command was resolved (disk fallback)." def test_run_command_resolves_and_executes(self, tmp_path): # Create a mock core command md file cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) cmd_file = cmd_dir / "test.md" cmd_file.write_text( "---\n" "description: \"Test\"\n" "scripts:\n" " sh: scripts/test.sh\n" "---\n" "Body\n", encoding="utf-8", ) script_dir = tmp_path / ".specify" / "scripts" script_dir.mkdir(parents=True) script_file = script_dir / "test.sh" script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") script_file.chmod(0o755) # Skip on Windows because sh is POSIX if platform.system().lower().startswith("win"): return code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path) assert code == 0 def test_py_variant_anchored_under_specify(self, tmp_path): """S2: the py variant resolves scripts/... under .specify/, not the project root, and prepends the resolved interpreter.""" from specify_cli.events import _resolve_event_command_argv cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) (cmd_dir / "boot.md").write_text( "---\n" "description: \"Boot\"\n" "scripts:\n" " py: scripts/python/boot.py\n" "---\nBody\n", encoding="utf-8", ) py_dir = tmp_path / ".specify" / "scripts" / "python" py_dir.mkdir(parents=True) (py_dir / "boot.py").write_text("import sys; sys.exit(0)\n", encoding="utf-8") argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # Interpreter first, then the .specify-anchored script path. Compare in # POSIX form so the assertion holds on Windows (backslash paths) too. assert len(argv) >= 2 assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py") assert ".specify" in argv[1] def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path): """S6: the ps variant prefixes argv with pwsh/powershell -File so subprocess.run(shell=False) can execute the .ps1 script.""" from specify_cli.events import _resolve_event_command_argv cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) (cmd_dir / "boot.md").write_text( "---\n" "description: \"Boot\"\n" "scripts:\n" " ps: scripts/powershell/boot.ps1\n" "---\nBody\n", encoding="utf-8", ) ps_dir = tmp_path / ".specify" / "scripts" / "powershell" ps_dir.mkdir(parents=True) (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8") argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # Launcher (pwsh or powershell), -File, then the .specify-anchored # script. shutil.which may return a full path with an .EXE suffix on # Windows, so match by stem (case-insensitive). assert PurePath(argv[0]).stem.lower() in ("pwsh", "powershell") assert argv[1] == "-File" assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1") def test_run_command_executes_with_project_root_cwd(self, tmp_path): """R1: the event command runs with cwd set to the project root, not the caller's arbitrary working directory, so project-relative script logic resolves correctly even when the agent fires the hook elsewhere.""" if platform.system().lower().startswith("win"): return cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) (cmd_dir / "cwd.md").write_text( "---\ndescription: \"cwd\"\nscripts:\n sh: scripts/cwd.sh\n---\nBody\n", encoding="utf-8", ) script_dir = tmp_path / ".specify" / "scripts" script_dir.mkdir(parents=True) out_file = tmp_path / "cwd.out" script = script_dir / "cwd.sh" # The script records its working directory. script.write_text(f"#!/bin/sh\npwd > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8") script.chmod(0o755) # Invoke from a different working directory to prove cwd is forced. import os as _os prev = _os.getcwd() subdir = tmp_path / "sub" subdir.mkdir() try: _os.chdir(subdir) code = resolve_and_run_event_command("speckit.cwd", "session_start", "{}", tmp_path) finally: _os.chdir(prev) assert code == 0 recorded = out_file.read_text().strip() assert Path(recorded).resolve() == tmp_path.resolve() def test_dispatcher_is_self_contained(self, tmp_path): """R1: the generated dispatcher prefers `import specify_cli` (durable install) and falls back to an inline stdlib resolver so it works without a persistent `specify` executable (e.g. one-time uvx).""" integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) content = (tmp_path / EVENTS_DISPATCHER_REL).read_text() # Delegates to specify_cli when importable. assert "from specify_cli.events import resolve_and_run_event_command" in content assert "except ImportError" in content # Inline stdlib fallback resolver for one-time/temporary installs. assert "_run_inline" in content assert "_find_command_template" in content # No dependency on a persistent `specify` executable. assert '["specify"]' not in content def test_dispatcher_inline_fallback_runs_script(self, tmp_path): """R1: with specify_cli.events NOT importable, the inline resolver finds the command template and runs its script (stdlib only).""" import subprocess as _sp import sys as _sys # Install events (generates the dispatcher + native config). integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) dispatcher = tmp_path / EVENTS_DISPATCHER_REL assert dispatcher.is_file() # Create a core command template whose script writes its payload. cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) out_file = tmp_path / "payload.out" (cmd_dir / "boot.md").write_text( "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/boot.sh\n---\nBody\n", encoding="utf-8", ) script_dir = tmp_path / ".specify" / "scripts" script_dir.mkdir(parents=True) script = script_dir / "boot.sh" script.write_text(f"#!/bin/sh\ncat > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8") script.chmod(0o755) if platform.system().lower().startswith("win"): return # sh is POSIX # Force the inline fallback: shadow `specify_cli` with an empty package # (no `events` submodule) so `from specify_cli.events import ...` raises # ModuleNotFoundError (an ImportError subclass), simulating a one-time # install where the package is unavailable at runtime. fake_dir = tmp_path / "_fake" (fake_dir / "specify_cli").mkdir(parents=True) (fake_dir / "specify_cli" / "__init__.py").write_text("", encoding="utf-8") env = dict(os.environ) env["PYTHONPATH"] = str(fake_dir) result = _sp.run( [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"], input='{"tool_name":"x"}', capture_output=True, text=True, env=env, cwd=str(tmp_path), ) # The inline resolver ran the script with the payload. assert out_file.exists(), f"inline fallback did not run script; stderr={result.stderr!r} rc={result.returncode}" assert out_file.read_text() == '{"tool_name":"x"}' def test_dispatcher_threads_per_handler_timeout(self, tmp_path): """S4: the generated dispatcher reads an optional 4th timeout arg and uses it for the inner subprocess, instead of a fixed 120s cap that would kill a handler configured for longer.""" integration = ClaudeIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate", "timeout": 300}]}, ) content = (tmp_path / EVENTS_DISPATCHER_REL).read_text() # The dispatcher accepts a 4th argv element as the timeout. assert "sys.argv[3]" in content assert "timeout=timeout" in content def test_native_command_carries_resolved_timeout(self, tmp_path): """S4: the generated native hook command appends the resolved timeout so the dispatcher receives it. Claude uses seconds (default unit).""" from specify_cli.events import _dispatcher_command cmd = _dispatcher_command( ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", timeout_seconds=300, ) # R2: the raw seconds (no conversion, no buffer) are appended as the # 4th arg; the buffer goes on the native hook timeout field instead. assert " 300" in cmd def test_dispatcher_timeout_not_unit_converted_for_ms_adapters(self, tmp_path): """R2: the dispatcher arg is always seconds — for Gemini/Qwen/Tabnine (ms adapters) the timeout must NOT be converted to milliseconds (which previously yielded 60000 seconds).""" from specify_cli.events import _dispatcher_command from specify_cli.integrations.gemini import GeminiIntegration cmd = _dispatcher_command( GeminiIntegration(), tmp_path, "speckit.x.y", "pre_tool_use", timeout_seconds=60, ) # 60 seconds (not 60000) is passed to the dispatcher. assert " 60" in cmd assert " 60000" not in cmd def test_sh_variant_uses_launcher_on_windows(self, tmp_path): """S5: on Windows the sh variant prefixes a bash/sh launcher so subprocess.run(shell=False) can execute the .sh script.""" from specify_cli.events import _resolve_event_command_argv cmd_dir = tmp_path / ".specify" / "templates" / "commands" cmd_dir.mkdir(parents=True) (cmd_dir / "boot.md").write_text( "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/bash/boot.sh\n---\nBody\n", encoding="utf-8", ) sh_dir = tmp_path / ".specify" / "scripts" / "bash" sh_dir.mkdir(parents=True) (sh_dir / "boot.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None) assert argv is not None # On POSIX the script runs directly; on Windows a launcher prefixes it. if platform.system().lower().startswith("win"): assert PurePath(argv[0]).stem.lower() in ("bash", "sh") assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/bash/boot.sh") else: assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh") # -- Merge/teardown idempotency & safety (Tier 3) ---------------------------- def _claude_manifest(tmp_path): manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() return manifest class TestMergeIdempotency: """#9/#11: marker recursion and full-clean-before-add.""" def test_upgrade_does_not_duplicate_nested_hooks(self, tmp_path): """#9: re-running install replaces prior Specify inner hooks instead of appending a second matcher-group on every upgrade.""" integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) events = {"pre_tool_use": [{"command": "speckit.tdd.validate"}]} for _ in range(2): manifest = _claude_manifest(tmp_path) install_integration_events(integration, tmp_path, manifest, events) data = json.loads(config_path.read_text()) groups = data["hooks"]["PreToolUse"] # Exactly one matcher-group for Specify (no duplication). assert len(groups) == 1 inner = groups[0]["hooks"] assert len(inner) == 1 assert "speckit.tdd.validate" in inner[0]["command"] def test_override_change_removes_stale_event(self, tmp_path): """#11: when the resolved set changes from pre_tool_use to stop, the old marked pre_tool_use entry is removed, not left active.""" integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"stop": [{"command": "speckit.end"}]}, ) data = json.loads(config_path.read_text()) assert "PreToolUse" not in data["hooks"] assert "Stop" in data["hooks"] class TestEmptyMapRemoval: """#3: --events false / empty resolved map strips prior hooks.""" def test_empty_events_removes_prior_hooks(self, tmp_path): integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) assert config_path.is_file() # Now resolve to empty (--events false): prior hooks must be removed. install_integration_events(integration, tmp_path, _claude_manifest(tmp_path), {}) # The dispatcher is shared and left in place (#10); only native hooks # are stripped. The settings file had no user content → deleted (#14). assert not config_path.exists() or "hooks" not in json.loads(config_path.read_text()) class TestTeardownDataSafety: """#14/#22/#23: preserve user content, delete Spec-Kit-created empties.""" def test_remove_deletes_spec_kit_created_config(self, tmp_path): """#14: a config Spec Kit created from scratch is deleted (not left as ``{}``) so manifest.uninstall() doesn't preserve an empty stub.""" integration = ClaudeIntegration() manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) config_path = tmp_path / ".claude/settings.json" assert config_path.is_file() remove_integration_events(integration, tmp_path, manifest) assert not config_path.exists() def test_remove_preserves_user_content_in_config(self, tmp_path): """#14: a pre-existing config with user content is kept (user hooks survive teardown).""" integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{"type": "command", "command": "user-check"}], }] }, "userSetting": True, })) manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"stop": [{"command": "speckit.end"}]}, ) remove_integration_events(integration, tmp_path, manifest) data = json.loads(config_path.read_text()) # User hook and setting preserved; Specify hook gone. assert data["userSetting"] is True assert "Stop" not in data.get("hooks", {}) assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" def test_forced_full_teardown_preserves_user_config(self, tmp_path): """S9: a full teardown(force=True) — which runs manifest.uninstall( force=True) after remove_events — must not delete a pre-existing user settings file whose owned entries were cleaned but user content kept. Uses a real manifest to exercise the uninstall path.""" from specify_cli.integrations.manifest import IntegrationManifest integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({ "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{"type": "command", "command": "user-check"}], }] }, "userSetting": True, })) manifest = IntegrationManifest(integration.key, tmp_path, version="test") install_integration_events( integration, tmp_path, manifest, {"stop": [{"command": "speckit.end"}]}, ) manifest.save() # Full teardown: remove_events + manifest.uninstall(force=True). integration.teardown(tmp_path, manifest, force=True) assert config_path.exists(), ( "Forced teardown deleted the user's settings file (S9)." ) data = json.loads(config_path.read_text()) assert data["userSetting"] is True assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash" def test_jsonc_config_not_reset_on_merge(self, tmp_path): """#22: a JSONC/unparseable native config is left untouched on merge.""" integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) jsonc = '{\n // my comment\n "hooks": {}\n}\n' config_path.write_text(jsonc) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) # User content preserved verbatim — not reset to {}. assert config_path.read_text() == jsonc def test_jsonc_opencode_config_not_reset(self, tmp_path): """#23: a malformed opencode.json is preserved, not reset to {}.""" integration = OpencodeIntegration() config_path = tmp_path / "opencode.json" config_path.parent.mkdir(parents=True, exist_ok=True) malformed = "{ not valid json" config_path.write_text(malformed) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"session_start": [{"command": "speckit.boot"}]}, ) assert config_path.read_text() == malformed class TestCopilotMergeTeardown: """#8: Copilot dedicated hooks JSON merges owned entries / teardown removes only owned entries.""" def test_copilot_merge_preserves_user_hooks(self, tmp_path): integration = CopilotIntegration() config_path = tmp_path / ".github/hooks/speckit.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({ "version": 1, "hooks": { "sessionStart": [{"type": "command", "bash": "user-hook"}], }, })) install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"session_start": [{"command": "speckit.boot"}]}, ) data = json.loads(config_path.read_text()) entries = data["hooks"]["sessionStart"] bash_cmds = [e.get("bash") for e in entries] assert "user-hook" in bash_cmds assert any("speckit.boot" in c for c in bash_cmds) def test_copilot_teardown_removes_only_owned_entries(self, tmp_path): integration = CopilotIntegration() config_path = tmp_path / ".github/hooks/speckit.json" config_path.parent.mkdir(parents=True, exist_ok=True) config_path.write_text(json.dumps({ "version": 1, "hooks": { "sessionStart": [{"type": "command", "bash": "user-hook"}], }, })) manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) remove_integration_events(integration, tmp_path, manifest) data = json.loads(config_path.read_text()) # User hook preserved; Spec-Kit entry gone. assert data["hooks"]["sessionStart"][0]["bash"] == "user-hook" def test_copilot_teardown_deletes_spec_kit_only_file(self, tmp_path): """#8/#14: when the file held only Spec-Kit entries, teardown deletes it.""" integration = CopilotIntegration() manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) config_path = tmp_path / ".github/hooks/speckit.json" assert config_path.is_file() remove_integration_events(integration, tmp_path, manifest) assert not config_path.exists() class TestSharedDispatcherRefcount: """#10: the shared .specify/events.py dispatcher is not deleted while another installed event-capable integration still references it.""" def test_dispatcher_kept_when_other_integration_references_it(self, tmp_path): # Simulate two event-capable integrations installed: claude (the one # being uninstalled) and codex (still installed). The codex manifest # lists the dispatcher, so removing claude must not delete it. from specify_cli.integrations.codex import CodexIntegration claude = ClaudeIntegration() codex = CodexIntegration() # Install claude's events (writes dispatcher + claude config). claude_manifest = _claude_manifest(tmp_path) install_integration_events( claude, tmp_path, claude_manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) # Install codex's events (re-writes shared dispatcher + codex config). codex_manifest = MagicMock(spec=IntegrationManifest) codex_manifest.files = {} codex_manifest.record_file = MagicMock() codex_manifest.record_existing = MagicMock() codex_manifest.remove = MagicMock() install_integration_events( codex, tmp_path, codex_manifest, {"pre_tool_use": [{"command": "speckit.codex.check"}]}, ) # Persist a codex manifest on disk so the refcount check finds it. codex_disk = IntegrationManifest(codex.key, tmp_path, version="test") codex_disk._files = {EVENTS_DISPATCHER_REL: "x"} codex_disk.save() # Write the integration-state JSON so installed_integration_keys sees codex. import json as _json state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(_json.dumps({ "default_integration": "claude", "installed_integrations": ["claude", "codex"], })) dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL assert dispatcher_path.exists() # Removing claude should leave the dispatcher (codex still uses it). remove_integration_events(claude, tmp_path, claude_manifest) assert dispatcher_path.exists() class TestSafeWriteDestination: """#12: write targets are validated before any bytes are written.""" def test_symlinked_config_dir_rejected(self, tmp_path): integration = ClaudeIntegration() # Create a symlinked .claude directory pointing outside the project. outside = tmp_path / "outside" outside.mkdir() linked = tmp_path / ".claude" os.symlink(outside, linked) with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): install_integration_events( integration, tmp_path, _claude_manifest(tmp_path), {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) # No content written through the symlink. assert not (outside / "settings.json").exists() def test_toml_teardown_rejects_symlinked_config(self, tmp_path): """R3: TOML teardown validates the destination before read/write, so a symlink swap after install can't make uninstall overwrite an external file.""" from specify_cli.integrations.codex import CodexIntegration integration = CodexIntegration() manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) config_path = tmp_path / ".codex" / "config.toml" assert config_path.is_file() # Swap the config for a symlink pointing outside the project. outside = tmp_path / "outside.toml" outside.write_text("external = true\n") config_path.unlink() os.symlink(outside, config_path) with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): remove_integration_events(integration, tmp_path, manifest) # External file untouched. assert outside.read_text() == "external = true\n" def test_json_remover_rejects_symlinked_config(self, tmp_path): """Removers validate destination before reading or unlinking.""" integration = ClaudeIntegration() manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) config_path = tmp_path / ".claude" / "settings.json" assert config_path.is_file() outside = tmp_path / "outside.json" outside.write_text('{"external": true}') config_path.unlink() os.symlink(outside, config_path) with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): remove_integration_events(integration, tmp_path, manifest) assert outside.read_text() == '{"external": true}' def test_plugin_remover_rejects_symlinked_plugin(self, tmp_path): """OpenCode plugin cleanup validates destination before unlinking.""" integration = OpencodeIntegration() manifest = _claude_manifest(tmp_path) install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) plugin_path = tmp_path / ".opencode" / "plugin" / "speckit-events.ts" assert plugin_path.is_file() outside = tmp_path / "outside.ts" outside.write_text("// external") plugin_path.unlink() os.symlink(outside, plugin_path) manifest.files[".opencode/plugin/speckit-events.ts"] = "hash" with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"): remove_integration_events(integration, tmp_path, manifest) assert outside.read_text() == "// external" # -- Validation & lifecycle (Tier 4) ----------------------------------------- class TestValidateEventsCommandType: """#17: command must be a non-empty string, not just truthy.""" def test_non_string_command_rejected(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": ["speckit.tdd.validate"]}}} with pytest.raises(ValidationError, match="(?i)command.*string"): validate_events(data) def test_empty_string_command_rejected(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": " "}}} with pytest.raises(ValidationError, match="(?i)command.*string"): validate_events(data) class TestCollectExtensionEventsEnabledFlag: """#1: collect_extension_events honors the registry's enabled flag.""" def test_disabled_extension_events_skipped(self, tmp_path): from specify_cli.extensions import ExtensionRegistry ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n" " description: test\n" "schema_version: '1.0'\n" "requires:\n speckit_version: '>=0.1'\n" "provides:\n commands: []\n" "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) registry = ExtensionRegistry(tmp_path / ".specify" / "extensions") registry.add("my-ext", {"enabled": False}) result = collect_extension_events(tmp_path) assert result == {} def test_enabled_extension_events_collected(self, tmp_path): from specify_cli.extensions import ExtensionRegistry ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n" " description: test\n" "schema_version: '1.0'\n" "requires:\n speckit_version: '>=0.1'\n" "provides:\n commands: []\n" "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) registry = ExtensionRegistry(tmp_path / ".specify" / "extensions") registry.add("my-ext", {"enabled": True}) result = collect_extension_events(tmp_path) assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]} class TestRefreshIntegrationEvents: """#1: refresh_integration_events regenerates native config after extension state changes.""" def test_refresh_strips_removed_extension_events(self, tmp_path): from specify_cli.events import refresh_integration_events from specify_cli.integrations.manifest import IntegrationManifest # Install claude with an event sourced from a (simulated) extension. integration = ClaudeIntegration() manifest = IntegrationManifest(integration.key, tmp_path, version="test") install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.my-ext.check"}]}, ) manifest.save() config_path = tmp_path / ".claude/settings.json" assert config_path.is_file() # Record claude as installed so refresh finds it. state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "claude", "installed_integrations": ["claude"], })) # No extension declares events now → refresh should strip the prior hook # (the config is deleted when no user content remains, #14). refresh_integration_events(tmp_path) if config_path.exists(): data = json.loads(config_path.read_text()) assert "PreToolUse" not in data.get("hooks", {}) # If the file is gone, the hooks were stripped (and the empty config # deleted) — also correct. def test_refresh_emits_newly_declared_extension_events(self, tmp_path): from specify_cli.events import refresh_integration_events from specify_cli.integrations.manifest import IntegrationManifest integration = ClaudeIntegration() manifest = IntegrationManifest(integration.key, tmp_path, version="test") # Initially no events. manifest.save() config_path = tmp_path / ".claude/settings.json" # Declare an extension event on disk. ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "claude", "installed_integrations": ["claude"], })) refresh_integration_events(tmp_path) data = json.loads(config_path.read_text()) assert "SessionStart" in data["hooks"] def test_refresh_honors_stored_events_false(self, tmp_path): """S7: a stored --events false must be honored across extension lifecycle refresh; passing None would re-enable events.""" from specify_cli.events import refresh_integration_events from specify_cli.integrations.manifest import IntegrationManifest integration = ClaudeIntegration() manifest = IntegrationManifest(integration.key, tmp_path, version="test") manifest.save() config_path = tmp_path / ".claude/settings.json" # Declare an extension event on disk. ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) # Store the integration with --events false in parsed_options. state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "claude", "installed_integrations": ["claude"], "integration_settings": { "claude": {"parsed_options": {"events": "false"}}, }, })) refresh_integration_events(tmp_path) # No hooks should have been re-created (CLI gate honored). if config_path.exists(): assert "hooks" not in json.loads(config_path.read_text()) # -- Override preserve-layers (#10) ------------------------------------------ class TestOverridePreserveLayers: """#10: an invalid override entry abandons the whole override and keeps the accumulated built-in + extension layers, instead of disabling all hooks.""" def test_invalid_override_entry_keeps_prior_layers(self, tmp_path): override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) # One valid entry, one invalid (non-string command) — the whole # override is ignored, built-in defaults survive. override_file.write_text( "integrations:\n" " claude:\n" " events:\n" " stop:\n" " command: speckit.valid.stop\n" " pre_tool_use:\n" " command: [not-a-string]\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) # Built-in default survived (override was abandoned on the invalid entry). assert "post_tool_use" in result assert result["post_tool_use"] == [{"command": "speckit.tdd.validate"}] def test_explicit_empty_override_disables(self, tmp_path): """A fully-valid explicit `events: {}` override still disables.""" override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) override_file.write_text( "integrations:\n" " claude:\n" " events: {}\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) assert result == {} def test_empty_handler_override_abandons_override(self, tmp_path): """C4: a malformed handler (`stop: []` or `stop: bad-value`) abandons the whole override and keeps prior layers, rather than disabling.""" override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) override_file.write_text( "integrations:\n" " claude:\n" " events:\n" " stop: []\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) # Built-in default survived (override abandoned on the empty handler). assert "post_tool_use" in result def test_non_mapping_integration_entry_abandons_override(self, tmp_path): """C6: a non-mapping integration entry (`claude: bad`) is ignored as malformed, not treated as a valid explicit disable.""" override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) override_file.write_text( "integrations:\n" " claude: bad\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}}, tmp_path, None, ) # Built-in default survived (override ignored as malformed). assert "post_tool_use" in result # -- Matcher string validation (C10) ----------------------------------------- class TestMatcherValidation: """C10: matcher must be a string; a non-string matcher is rejected at validation time so it can't crash by_matcher.setdefault later.""" def test_non_string_matcher_rejected_in_manifest(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "matcher": []}}} with pytest.raises(ValidationError, match="(?i)matcher.*string"): validate_events(data) def test_non_string_matcher_rejected_in_override(self, tmp_path): override_file = tmp_path / ".specify" / "integration-events.yml" override_file.parent.mkdir(parents=True, exist_ok=True) # A matcher of [] would crash by_matcher.setdefault; the override must # be abandoned (built-in defaults survive) rather than crash. override_file.write_text( "integrations:\n" " claude:\n" " events:\n" " pre_tool_use:\n" " command: speckit.x.y\n" " matcher: []\n", encoding="utf-8", ) result = resolve_events( "claude", {"events": {"stop": {"command": "speckit.end"}}}, tmp_path, None, ) # Built-in default survived (override abandoned on the bad matcher). assert "stop" in result class TestTimeoutValidation: """Validate that non-integer, boolean, or non-positive timeouts are rejected.""" def test_non_int_timeout_rejected_in_manifest(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": "60"}}} with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"): validate_events(data) def test_boolean_timeout_rejected_in_manifest(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": True}}} with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"): validate_events(data) def test_zero_or_negative_timeout_rejected_in_manifest(self): from specify_cli.extensions import ValidationError data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": 0}}} with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"): validate_events(data) # -- Event command-ref canonicalization (C11) -------------------------------- class TestEventCommandRefCanonicalization: """C11: an event referencing a command that was auto-corrected is itself rewritten to the canonical name (mirrors hook reference rewriting).""" def test_event_command_ref_lifted_to_canonical(self, tmp_path): from specify_cli.extensions import ExtensionManifest import yaml as _yaml manifest_path = tmp_path / "extension.yml" manifest_path.write_text( _yaml.dump({ "schema_version": "1.0", "extension": { "id": "my-ext", "name": "My Ext", "version": "1.0.0", "description": "test", }, "requires": {"speckit_version": ">=0.1"}, "provides": { "commands": [ {"name": "speckit.my-ext.boot", "file": "commands/boot.md"} ] }, "events": { "session_start": {"command": "my-ext.boot"}, }, }), encoding="utf-8", ) manifest = ExtensionManifest(manifest_path) assert manifest.data["events"]["session_start"]["command"] == "speckit.my-ext.boot" assert any( "Event 'session_start' referenced command 'my-ext.boot'" in w for w in manifest.warnings ) # -- Skipped-merge not tracked (S5) ------------------------------------------ class TestSkippedMergeNotTracked: """S5: when a merge is skipped on parse failure, the untouched file is not recorded in the manifest, so uninstall() won't later delete it.""" def test_jsonc_native_config_not_tracked(self, tmp_path): integration = ClaudeIntegration() config_path = tmp_path / ".claude/settings.json" config_path.parent.mkdir(parents=True, exist_ok=True) jsonc = '{\n // my comment\n "hooks": {}\n}\n' config_path.write_text(jsonc) manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) # The JSONC file must NOT have been recorded (would cause uninstall() # to delete it later). Only the dispatcher (which we wrote) is tracked. recorded_rels = [c.args[0] for c in manifest.record_existing.call_args_list] assert str(config_path.relative_to(tmp_path)) not in recorded_rels # User content preserved verbatim. assert config_path.read_text() == jsonc # -- Dispatcher manifest claim dropped on retain (S1) ------------------------ class TestDispatcherManifestClaimDroppedOnRetain: """S1: when the dispatcher is retained (another integration references it), this integration's manifest still drops its claim so the subsequent manifest.uninstall() in teardown() doesn't delete the shared file.""" def test_full_teardown_keeps_dispatcher_when_other_references_it(self, tmp_path): from specify_cli.integrations.codex import CodexIntegration from specify_cli.integrations.manifest import IntegrationManifest claude = ClaudeIntegration() codex = CodexIntegration() # Install claude's events (writes dispatcher + claude config). claude_manifest = IntegrationManifest(claude.key, tmp_path, version="test") install_integration_events( claude, tmp_path, claude_manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) claude_manifest.save() # Install codex's events (re-writes shared dispatcher + codex config). codex_manifest = IntegrationManifest(codex.key, tmp_path, version="test") install_integration_events( codex, tmp_path, codex_manifest, {"pre_tool_use": [{"command": "speckit.codex.check"}]}, ) codex_manifest.save() # integration.json: both installed, codex is default. state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "codex", "installed_integrations": ["claude", "codex"], })) dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL assert dispatcher_path.exists() # Full teardown of claude (remove + manifest.uninstall): the dispatcher # must survive because codex's manifest still references it. remove_integration_events(claude, tmp_path, claude_manifest) claude_manifest.uninstall(tmp_path, force=True) assert dispatcher_path.exists(), ( "Shared dispatcher was deleted by teardown() despite another " "integration referencing it (S1)." ) def test_empty_map_upgrade_deletes_dispatcher_for_last_integration(self, tmp_path): """S3: an --events false upgrade (empty resolved map) of the last event-capable integration deletes the shared dispatcher instead of orphaning it (the new manifest wouldn't claim it and stale cleanup excludes it).""" from specify_cli.integrations.codex import CodexIntegration from specify_cli.integrations.manifest import IntegrationManifest claude = ClaudeIntegration() codex = CodexIntegration() # Install both so the dispatcher is shared. cm = IntegrationManifest(claude.key, tmp_path, version="test") install_integration_events(claude, tmp_path, cm, {"pre_tool_use": [{"command": "speckit.x"}]}) cm.save() xm = IntegrationManifest(codex.key, tmp_path, version="test") install_integration_events(codex, tmp_path, xm, {"pre_tool_use": [{"command": "speckit.y"}]}) xm.save() state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "codex", "installed_integrations": ["claude", "codex"], })) dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL assert dispatcher_path.exists() # Codex upgrades to --events false (empty map): claude still references # the dispatcher, so it must be retained. install_integration_events(codex, tmp_path, xm, {}) xm.save() assert dispatcher_path.exists(), "Dispatcher deleted while claude still references it." # Now claude also goes --events false: no integration references the # dispatcher, so it must be deleted (not orphaned). install_integration_events(claude, tmp_path, cm, {}) cm.save() assert not dispatcher_path.exists(), ( "Dispatcher orphaned after the last event integration disabled events (S3)." ) def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path): """S2: an upgrade passing a *fresh* manifest (that never recorded the dispatcher) still deletes the shared dispatcher when no other integration references it — the deletion isn't gated on the new manifest's claim.""" from specify_cli.integrations.manifest import IntegrationManifest claude = ClaudeIntegration() # Install claude with an old manifest that records the dispatcher. old = IntegrationManifest(claude.key, tmp_path, version="test") install_integration_events(claude, tmp_path, old, {"pre_tool_use": [{"command": "speckit.x"}]}) old.save() state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "claude", "installed_integrations": ["claude"], })) dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL assert dispatcher_path.exists() # Simulate the upgrade path: a fresh manifest (like # IntegrationManifest(key, project_root, version=...) in # _migrate_commands) that never recorded the dispatcher. fresh = IntegrationManifest(claude.key, tmp_path, version="test") assert EVENTS_DISPATCHER_REL not in fresh.files install_integration_events(claude, tmp_path, fresh, {}) # The dispatcher must be deleted (no other integration references it), # not orphaned just because the fresh manifest didn't claim it. assert not dispatcher_path.exists(), ( "Dispatcher orphaned after upgrade with a fresh manifest (S2)." ) # -- Dispatcher stale-cleanup exclusion (C3) --------------------------------- class TestDispatcherStaleExclusion: """C3: the shared dispatcher is excluded from the generic upgrade stale pass so an --events false upgrade doesn't delete it and break other installed event-capable integrations.""" def test_dispatcher_in_stale_exclusions(self): from specify_cli.events import events_stale_exclusions exclusions = events_stale_exclusions("claude") assert EVENTS_DISPATCHER_REL in exclusions # -- Cursor version-only stub deletion (C5) ---------------------------------- class TestCursorVersionOnlyStubDeletion: """C5: a Spec-Kit-created Cursor file retaining only {"version": 1} after all owned hooks are removed is deleted, not left as a generated stub.""" def test_version_only_cursor_file_deleted_on_teardown(self, tmp_path): integration = CursorAgentIntegration() manifest = MagicMock(spec=IntegrationManifest) manifest.files = {} manifest.record_file = MagicMock() manifest.record_existing = MagicMock() manifest.remove = MagicMock() install_integration_events( integration, tmp_path, manifest, {"session_start": [{"command": "speckit.boot"}]}, ) config_path = tmp_path / ".cursor/hooks.json" assert config_path.is_file() assert json.loads(config_path.read_text()).get("version") == 1 remove_integration_events(integration, tmp_path, manifest) # No user content remained (only the Spec-Kit-managed version field) → # the file is deleted for a clean teardown, not left as a stub. assert not config_path.exists() # -- Non-destructive refresh (C12) ------------------------------------------- class TestNonDestructiveRefresh: """C12: refresh resolves first then installs once; a failure during install no longer destroys the working native config before the new one is written.""" def test_refresh_failure_preserves_existing_config(self, tmp_path): from specify_cli.events import refresh_integration_events from specify_cli.integrations.manifest import IntegrationManifest integration = ClaudeIntegration() manifest = IntegrationManifest(integration.key, tmp_path, version="test") install_integration_events( integration, tmp_path, manifest, {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}, ) manifest.save() config_path = tmp_path / ".claude/settings.json" original = config_path.read_text() # Declare an extension event so refresh would try to re-emit. ext_dir = tmp_path / ".specify" / "extensions" / "my-ext" ext_dir.mkdir(parents=True) (ext_dir / "extension.yml").write_text( "events:\n session_start:\n command: speckit.my-ext.boot\n", encoding="utf-8", ) state_path = tmp_path / ".specify" / "integration.json" state_path.parent.mkdir(parents=True, exist_ok=True) state_path.write_text(json.dumps({ "default_integration": "claude", "installed_integrations": ["claude"], })) # Force install_integration_events to fail mid-refresh. R3: the # failure is now surfaced as EventRefreshError (aggregated) rather # than silently swallowed. from specify_cli.events import EventRefreshError with patch( "specify_cli.events.install_integration_events", side_effect=RuntimeError("simulated write failure"), ): with pytest.raises(EventRefreshError, match="simulated write failure"): refresh_integration_events(tmp_path) # The pre-existing config was NOT destroyed before the failure # (install handles cleanup atomically; refresh no longer pre-strips). assert config_path.read_text() == original