@@ -2256,6 +2256,7 @@ def test_skip_broken_init_py(self, project_dir):
22562256
22572257 def test_module_name_sanitized_for_hyphenated_type_key (self , project_dir ):
22582258 """type_key values with hyphens produce valid Python module identifiers."""
2259+ import hashlib
22592260 import sys
22602261 from specify_cli .workflows import load_custom_steps , STEP_REGISTRY
22612262
@@ -2281,10 +2282,14 @@ def execute(self, config, context):
22812282 assert "my-hyphen-step" in loaded
22822283 assert "my-hyphen-step" in STEP_REGISTRY
22832284 # Synthetic module name must be a valid identifier (hyphens → underscores)
2284- assert "_speckit_custom_step_my_hyphen_step" in sys .modules
2285+ # and include a collision-resistant hash suffix.
2286+ key_hash = hashlib .sha256 (b"my-hyphen-step" ).hexdigest ()[:8 ]
2287+ module_name = f"_speckit_custom_step_my_hyphen_step_{ key_hash } "
2288+ assert module_name in sys .modules
22852289
22862290 def test_package_relative_import (self , project_dir ):
22872291 """Steps can use relative imports to access sibling modules."""
2292+ import hashlib
22882293 import sys
22892294 from specify_cli .workflows import load_custom_steps , STEP_REGISTRY
22902295
@@ -2314,7 +2319,94 @@ def execute(self, config, context):
23142319 loaded = load_custom_steps (project_dir )
23152320 assert "pkg-step" in loaded
23162321 assert "pkg-step" in STEP_REGISTRY
2317- # Verify the relative import actually resolved
2318- module_name = "_speckit_custom_step_pkg_step"
2322+ # Verify the relative import actually resolved; module name includes hash suffix.
2323+ key_hash = hashlib .sha256 (b"pkg-step" ).hexdigest ()[:8 ]
2324+ module_name = f"_speckit_custom_step_pkg_step_{ key_hash } "
23192325 assert module_name in sys .modules
23202326 assert sys .modules [module_name ].PkgStep .helper == "hello"
2327+
2328+ def test_module_name_collision_resistance (self , project_dir ):
2329+ """'a-b' and 'a_b' produce different module names despite the same sanitized form."""
2330+ import hashlib
2331+
2332+ # Simulate the module name generation for two type_keys that sanitize the same way
2333+ def make_module_name (type_key : str ) -> str :
2334+ import re
2335+ safe_key = re .sub (r"[^A-Za-z0-9_]" , "_" , type_key )
2336+ key_hash = hashlib .sha256 (type_key .encode ()).hexdigest ()[:8 ]
2337+ return f"_speckit_custom_step_{ safe_key } _{ key_hash } "
2338+
2339+ name_a = make_module_name ("a-b" )
2340+ name_b = make_module_name ("a_b" )
2341+ assert name_a != name_b , "Module names for 'a-b' and 'a_b' must differ"
2342+
2343+
2344+ # ===== CLI Step Remove Tests =====
2345+
2346+ class TestWorkflowStepRemoveCLI :
2347+ """Test the 'specify workflow step remove' CLI command edge cases."""
2348+
2349+ def test_remove_orphaned_directory (self , project_dir , monkeypatch ):
2350+ """step remove works when directory exists but registry entry is missing.
2351+
2352+ This covers the case where the registry was reset due to corruption.
2353+ """
2354+ from typer .testing import CliRunner
2355+ from specify_cli import app
2356+
2357+ monkeypatch .chdir (project_dir )
2358+
2359+ # Create an orphaned step directory (no registry entry)
2360+ step_dir = project_dir / ".specify" / "workflows" / "steps" / "orphan-step"
2361+ step_dir .mkdir (parents = True )
2362+ (step_dir / "step.yml" ).write_text (
2363+ "step:\n type_key: orphan-step\n " , encoding = "utf-8"
2364+ )
2365+ (step_dir / "__init__.py" ).write_text ("" , encoding = "utf-8" )
2366+
2367+ runner = CliRunner ()
2368+ result = runner .invoke (app , ["workflow" , "step" , "remove" , "orphan-step" ])
2369+
2370+ assert result .exit_code == 0 , result .output
2371+ assert not step_dir .exists ()
2372+ # Warning should be printed about missing registry entry
2373+ assert "Warning" in result .output or "warning" in result .output .lower ()
2374+
2375+ def test_remove_not_installed (self , project_dir , monkeypatch ):
2376+ """step remove fails cleanly when neither directory nor registry entry exist."""
2377+ from typer .testing import CliRunner
2378+ from specify_cli import app
2379+
2380+ monkeypatch .chdir (project_dir )
2381+
2382+ runner = CliRunner ()
2383+ result = runner .invoke (app , ["workflow" , "step" , "remove" , "ghost-step" ])
2384+
2385+ assert result .exit_code != 0
2386+ assert "not installed" in result .output
2387+
2388+ def test_remove_registered_step (self , project_dir , monkeypatch ):
2389+ """step remove works normally when both directory and registry entry exist."""
2390+ from typer .testing import CliRunner
2391+ from specify_cli import app
2392+ from specify_cli .workflows .catalog import StepRegistry
2393+
2394+ monkeypatch .chdir (project_dir )
2395+
2396+ # Set up a registered step with a directory
2397+ registry = StepRegistry (project_dir )
2398+ registry .add ("my-step" , {"name" : "My Step" , "type_key" : "my-step" , "version" : "1.0.0" })
2399+ step_dir = project_dir / ".specify" / "workflows" / "steps" / "my-step"
2400+ step_dir .mkdir (parents = True )
2401+ (step_dir / "step.yml" ).write_text (
2402+ "step:\n type_key: my-step\n " , encoding = "utf-8"
2403+ )
2404+ (step_dir / "__init__.py" ).write_text ("" , encoding = "utf-8" )
2405+
2406+ runner = CliRunner ()
2407+ result = runner .invoke (app , ["workflow" , "step" , "remove" , "my-step" ])
2408+
2409+ assert result .exit_code == 0 , result .output
2410+ assert not step_dir .exists ()
2411+ registry2 = StepRegistry (project_dir )
2412+ assert not registry2 .is_installed ("my-step" )
0 commit comments