fix(cli): harden extension registration and discovery workflows#2499
fix(cli): harden extension registration and discovery workflows#2499DyanGalih wants to merge 7 commits intogithub:mainfrom
Conversation
- Update memory-md from 0.7.9 to 0.8.0 - Update architecture-guard from 1.6.7 to 1.8.0
There was a problem hiding this comment.
Pull request overview
This PR strengthens how the Spec Kit CLI tracks installed extensions by ensuring hook registration/unregistration updates an installed list in .specify/extensions.yml, and adds tests to validate the intended behavior. It also updates metadata for a couple of community extensions in the catalog.
Changes:
- Add
installedlist maintenance toHookExecutor(register on hook registration; unregister on hook removal). - Introduce unit tests covering registration sorting/idempotency and initialization when
installedis missing. - Bump versions + download URLs (and
updated_at) for two entries inextensions/catalog.community.json.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
tests/test_extension_registration.py |
Adds new unit tests validating installed list behavior in .specify/extensions.yml. |
src/specify_cli/extensions.py |
Updates hook registration/unregistration to maintain an installed extension list; adds helper methods. |
extensions/catalog.community.json |
Updates version/download metadata for architecture-guard and memory-md. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| config = self.get_project_config() | ||
|
|
||
| if "installed" not in config: | ||
| config["installed"] = [] | ||
|
|
||
| if extension_id not in config["installed"]: | ||
| config["installed"].append(extension_id) | ||
| # Maintain alphabetical order for readability and diff stability | ||
| config["installed"].sort() | ||
| self.save_project_config(config) |
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
The code now normalizes both the config and the installed field before any mutation:
config = self.get_project_config()
# Ensure config is a dict (defensive)
if not isinstance(config, dict):
config = {}
# Ensure "installed" is a list (defensive)
if "installed" not in config or not isinstance(config["installed"], list):
config["installed"] = []
if extension_id not in config["installed"]:
config["installed"].append(extension_id)
config["installed"].sort()
self.save_project_config(config)Handles all edge cases: corrupted root (list/string) → {}, missing/null/non-list installed → []. The in check, .append(), and .sort() are all safe.
| # Register hooks and update installed list in extensions.yml | ||
| hook_executor = HookExecutor(self.project_root) | ||
| hook_executor.register_hooks(manifest) |
There was a problem hiding this comment.
Good catch. The PR description was written to reflect the full intended scope. The orchestration template changes are handled in a companion change in the spec-kit-architecture-guard extension. This PR focuses solely on the CLI hardening.
I'll update the PR description to clarify this separation to avoid confusion.
|
Addressing feedback:
|
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.
Comments suppressed due to low confidence (1)
src/specify_cli/extensions.py:2562
register_hooks()now always callsregister_extension(), but the subsequent hook registration path still assumesconfigis a dict and thatconfig["hooks"]is a dict. Sinceget_project_config()can return any YAML type and existing configs could have a non-mappinghooksvalue, consider hardening here too (e.g., return defaults/coerce whenconfigorconfig["hooks"]isn’t a mapping) so installs don’t crash on corruptedextensions.yml.
# Always ensure the extension is in the installed list
self.register_extension(manifest.id)
if not hasattr(manifest, "hooks") or not manifest.hooks:
return
config = self.get_project_config()
# Ensure hooks dict exists
if "hooks" not in config:
config["hooks"] = {}
| # Ensure "installed" is a list (defensive) | ||
| if "installed" not in config or not isinstance(config["installed"], list): | ||
| config["installed"] = [] | ||
|
|
||
| if extension_id not in config["installed"]: | ||
| config["installed"].append(extension_id) | ||
| # Maintain alphabetical order for readability and diff stability | ||
| config["installed"].sort() | ||
| self.save_project_config(config) |
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
The installed list is now coerced to [] when it is missing, null, or any non-list type — before any membership check or .sort() call. No TypeError can occur from non-string entries in a corrupted list.
| self.unregister_extension(extension_id) | ||
|
|
||
| config = self.get_project_config() | ||
|
|
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
unregister_hooks() now has an early isinstance(config, dict) guard:
config = self.get_project_config()
if "hooks" not in config or not isinstance(config["hooks"], dict):
returnIf the root config is a list or scalar, the method returns early without raising.
| config["hooks"][hook_name] = [ | ||
| h | ||
| for h in config["hooks"][hook_name] |
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
The list comprehension now guards with isinstance(h, dict) before calling .get():
config["hooks"][hook_name] = [
h
for h in config["hooks"][hook_name]
if isinstance(h, dict) and h.get("extension") != extension_id
]Non-dict values (null, scalars) are silently skipped rather than raising TypeError.
|
|
||
| manifest = ExtensionManifest(manifest_path) | ||
| executor = HookExecutor(project_dir) | ||
|
|
||
| # This should call register_extension internally | ||
| executor.register_hooks(manifest) | ||
|
|
||
| config = executor.get_project_config() | ||
| assert "hook-ext" in config["installed"] | ||
|
|
||
| def test_missing_installed_key_initialization(self, project_dir): | ||
| """Graceful Initialization: If 'installed' key is missing, it should be created.""" | ||
| executor = HookExecutor(project_dir) | ||
|
|
||
| # Manually create a config without 'installed' | ||
| config_path = project_dir / ".specify" / "extensions.yml" | ||
| config_path.write_text(yaml.dump({"settings": {"auto_execute_hooks": True}})) | ||
|
|
||
| # This should detect the missing key and initialize it | ||
| executor.register_extension("new-ext") | ||
|
|
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
Added the following integration tests covering both workflows:
test_register_hooks_triggers_registration—register_hooks()with a manifest that has hooks, verifiesinstalledis updatedtest_unregister_hooks_full_workflow— full removal: hooks removed +installedprunedtest_unregister_hooks_no_hooks_key—unregister_hooks()with nohookskey still prunesinstalledtest_unregister_hooks_corrupted_config— graceful handling of corrupted YAMLtest_unregister_hooks_with_multiple_extensions— only target extension is removed
All 11 tests passing. ✅
…ive unregister_hooks tests - Add dict guard to register_hooks() to handle corrupted extensions.yml (non-dict root) - Add 5 comprehensive tests for unregister_hooks() workflow: * Full workflow with hooks + installed list removal * Resilience when config has no 'hooks' key * Corrupted YAML handling * Multiple extension scenarios * All 11 tests passing
| # Ensure hooks dict exists | ||
| if "hooks" not in config: |
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
register_hooks() now normalizes the config before touching hooks:
config = self.get_project_config()
# Ensure config is a dict (defensive)
if not isinstance(config, dict):
config = {}
# Ensure hooks dict exists
if "hooks" not in config or not isinstance(config["hooks"], dict):
config["hooks"] = {}Both missing and non-dict hooks values (e.g., hooks: []) are reset to {} before any key access.
| config = self.get_project_config() | ||
|
|
||
| if "hooks" not in config: | ||
| if "hooks" not in config or not isinstance(config["hooks"], dict): | ||
| return | ||
|
|
||
| # Remove hooks for this extension | ||
| for hook_name in config["hooks"]: | ||
| for hook_name in list(config["hooks"].keys()): | ||
| config["hooks"][hook_name] = [ | ||
| h | ||
| for h in config["hooks"][hook_name] | ||
| if h.get("extension") != extension_id | ||
| if isinstance(h, dict) and h.get("extension") != extension_id | ||
| ] |
There was a problem hiding this comment.
✅ Fixed in commit 019acae.
unregister_hooks() now has both guards in place:
self.unregister_extension(extension_id) # always runs first
config = self.get_project_config()
if "hooks" not in config or not isinstance(config["hooks"], dict):
return # safe for non-dict root or missing/non-dict hooksAnd each hook list is filtered with isinstance(h, dict) to handle null/scalar entries:
config["hooks"][hook_name] = [
h for h in config["hooks"][hook_name]
if isinstance(h, dict) and h.get("extension") != extension_id
]| # Ensure "installed" is a list (defensive) | ||
| if "installed" not in config or not isinstance(config["installed"], list): | ||
| config["installed"] = [] | ||
|
|
||
| if extension_id not in config["installed"]: | ||
| config["installed"].append(extension_id) | ||
| # Maintain alphabetical order for readability and diff stability | ||
| config["installed"].sort() | ||
| self.save_project_config(config) |
There was a problem hiding this comment.
✅ Fixed in commit 51d19ce.
The installed list is now sanitized to strings-only before any membership check or sort:
if "installed" not in config or not isinstance(config["installed"], list):
config["installed"] = []
else:
# Sanitize: keep only strings to prevent TypeError on sort
config["installed"] = [x for x in config["installed"] if isinstance(x, str)]Non-string entries (e.g., 1, True) are silently dropped before .sort() is called. Regression test test_register_extension_mixed_type_installed verifies installed: [1, True, "existing-ext"] is handled without TypeError.
| # Ensure config is a dict (defensive) | ||
| if not isinstance(config, dict): | ||
| config = {} | ||
|
|
||
| # Ensure hooks dict exists | ||
| if "hooks" not in config: | ||
| config["hooks"] = {} |
There was a problem hiding this comment.
✅ Fixed in commit 51d19ce.
register_hooks() now normalizes hooks whether it is missing or non-dict:
# Ensure hooks dict exists and is a mapping
if "hooks" not in config or not isinstance(config["hooks"], dict):
config["hooks"] = {}hooks: [] or hooks: null are reset to {} before any key assignment.
| # Always remove from installed list (Feedback from review) | ||
| self.unregister_extension(extension_id) | ||
|
|
||
| config = self.get_project_config() | ||
|
|
||
| if "hooks" not in config: | ||
| if "hooks" not in config or not isinstance(config["hooks"], dict): | ||
| return |
There was a problem hiding this comment.
✅ Fixed in commit 51d19ce.
unregister_hooks() now has both an isinstance(config, dict) guard and coerces non-list hook values:
if not isinstance(config, dict):
return
if "hooks" not in config or not isinstance(config["hooks"], dict):
return
for hook_name in list(config["hooks"].keys()):
hook_list = config["hooks"][hook_name]
if not isinstance(hook_list, list):
config["hooks"][hook_name] = []
continue
config["hooks"][hook_name] = [
h for h in hook_list
if isinstance(h, dict) and h.get("extension") != extension_id
]A scalar config (123) returns early. A null/non-list hook value is reset to [] instead of raising TypeError.
| # Register hooks and update installed list in extensions.yml | ||
| hook_executor = HookExecutor(self.project_root) | ||
| hook_executor.register_hooks(manifest) |
There was a problem hiding this comment.
Agreed. Updated the PR description to clarify this PR is scoped to CLI hardening only. Orchestration template updates that consume the installed list are tracked as a companion change in spec-kit-architecture-guard.
|
|
||
| config = executor.get_project_config() | ||
| assert "hook-ext" in config["installed"] | ||
|
|
There was a problem hiding this comment.
✅ Fixed in commit 51d19ce.
Added test_register_hooks_no_hooks_still_registers — uses a commands-only manifest (no hooks key) and asserts the extension ID is still persisted in installed after register_hooks():
executor.register_hooks(manifest) # manifest has no hooks
config = executor.get_project_config()
assert "commands-only-ext" in config["installed"]All 14 tests passing. ✅
| config = executor.get_project_config() | ||
| assert "installed" in config | ||
| assert config["installed"] == ["new-ext"] | ||
|
|
There was a problem hiding this comment.
✅ Fixed in commit 51d19ce.
Added two targeted regression tests:
-
test_register_extension_mixed_type_installed— writesinstalled: [1, True, "existing-ext"]and verifies noTypeError, non-strings are dropped, valid strings are kept. -
test_unregister_hooks_null_hook_values— writeshooks: {after_tasks: null}and verifiesunregister_hooks()completes withoutTypeError, and the extension is removed frominstalled.
All 14 tests passing. ✅
…le null hook values
- register_extension(): filter non-string entries from installed before sort
- register_hooks(): normalize hooks to {} when missing or not a dict
- unregister_hooks(): add isinstance(config, dict) guard before key checks
- unregister_hooks(): coerce null/scalar hook lists to [] before iteration
- tests: add 3 regression tests for no-hooks manifest, mixed-type installed, null hook values
- All 14 tests passing
This PR hardens the extension registration process in the Spec Kit CLI and updates governance orchestration workflows to ensure robust extension discovery.
Key Changes