feat(extensions,presets): add priority-based resolution ordering (#1855)

* feat(extensions,presets): add priority-based resolution ordering

Add priority field to extension and preset registries for deterministic
template resolution when multiple sources provide the same template.

Extensions:
- Add `list_by_priority()` method to ExtensionRegistry
- Add `--priority` option to `extension add` command
- Add `extension set-priority` command
- Show priority in `extension list` and `extension info`
- Preserve priority during `extension update`
- Update RFC documentation

Presets:
- Add `preset set-priority` command
- Show priority in `preset info` output
- Use priority ordering in PresetResolver for extensions

Both systems:
- Lower priority number = higher precedence (default: 10)
- Backwards compatible with legacy entries (missing priority defaults to 10)
- Comprehensive test coverage including backwards compatibility

Closes #1845
Closes #1854

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

* fix: address code review feedback

- list_by_priority(): add secondary sort by ID for deterministic ordering,
  return deep copies to prevent mutation
- install_from_directory/zip: validate priority >= 1 early
- extension add CLI: validate --priority >= 1 before install
- PresetRegistry.update(): preserve installed_at timestamp
- Test assertions: use exact source string instead of substring match

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

* fix: address additional review feedback

- PresetResolver: add fallback to directory scanning when registry is
  empty/corrupted for robustness and backwards compatibility
- PresetRegistry.update(): add guard to prevent injecting installed_at
  when absent in existing entry (mirrors ExtensionRegistry behavior)
- RFC: update extension list example to match actual CLI output format

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

* fix: restore defensive code and RFC descriptions lost in rebase

- Restore defensive code in list_by_priority() with .get() and isinstance check
- Restore detailed --from URL and --dev option descriptions in RFC

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

* fix: add defensive code to presets list_by_priority()

- Add .get() and isinstance check for corrupted/empty registry
- Move copy import to module level (remove local import)
- Matches defensive pattern used in extensions.py

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

* fix: address reviewer feedback on priority resolution

- Rename _normalize_priority to normalize_priority (public API)
- Add comprehensive tests for normalize_priority function (9 tests)
- Filter non-dict metadata entries in list_by_priority() methods
- Fix extension priority resolution to merge registered and unregistered
  extensions into unified sorted list (unregistered get implicit priority 10)
- Add tests for extension priority resolution ordering (4 tests)

The key fix ensures unregistered extensions with implicit priority 10
correctly beat registered extensions with priority > 10, and vice versa.

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

* fix: DRY refactor and strengthen test assertions

- Extract _get_all_extensions_by_priority() helper in PresetResolver
  to eliminate duplicated extension list construction
- Add priority=10 assertion to test_legacy_extension_without_priority_field
- Add priority=10 assertion to test_legacy_preset_without_priority_field

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

* fix: add isinstance(dict) checks for corrupted registry entries

Add defensive checks throughout CLI commands and manager methods
to handle cases where registry entries may be corrupted (non-dict
values). This prevents AttributeError when calling .get() on
non-dict metadata.

Locations fixed:
- __init__.py: preset/extension info, set-priority, enable/disable,
  upgrade commands
- extensions.py: list_installed()
- presets.py: list_installed()

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

* fix: normalize priority display to match resolution behavior

Use normalize_priority() for all priority display in CLI commands
to ensure displayed values match actual resolution behavior when
registry data is corrupted/hand-edited.

Locations fixed:
- extensions.py: list_installed()
- presets.py: list_installed(), PresetResolver
- __init__.py: preset info, extension info, set-priority commands

Also added GraphQL query for unresolved PR comments to CLAUDE.md.

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

* fix: repair corrupted priority values in set-priority commands

Changed set-priority commands to check if the raw stored value is
already a valid int equal to the requested priority before skipping.
This ensures corrupted values (e.g., "high") get repaired even when
setting to the default priority (10).

Also removed CLAUDE.md that was accidentally added to the repo.

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

* fix: harden registry update methods against corrupted entries

- Normalize priority when restoring during extension update to prevent
  propagating corrupted values (e.g., "high", 0, negative)
- Add isinstance(dict) checks in ExtensionRegistry.update() and
  PresetRegistry.update() to handle corrupted entries (string/list)
  that would cause TypeError on merge

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

* fix: use safe fallback for version in list_installed()

When registry entry is corrupted (non-dict), metadata becomes {} after
the isinstance check. Use metadata.get("version", manifest.version)
instead of metadata["version"] to avoid KeyError.

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

---------

Co-authored-by: iamaeroplane <michal.bachorik@gmail.com>
Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Michal Bachorik
2026-03-17 15:44:34 +01:00
committed by GitHub
parent 7a5762fe6a
commit d2ecf6560d
6 changed files with 1113 additions and 61 deletions

View File

@@ -2000,6 +2000,11 @@ def preset_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
speckit_version = get_speckit_version()
@@ -2177,6 +2182,7 @@ def preset_info(
pack_id: str = typer.Argument(..., help="Preset ID to get info about"),
):
"""Show detailed information about a preset."""
from .extensions import normalize_priority
from .presets import PresetCatalog, PresetManager, PresetError
project_root = Path.cwd()
@@ -2210,6 +2216,10 @@ def preset_info(
if license_val:
console.print(f" License: {license_val}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(pack_id)
priority = normalize_priority(pack_metadata.get("priority") if isinstance(pack_metadata, dict) else None)
console.print(f" [dim]Priority:[/dim] {priority}")
console.print()
return
@@ -2241,6 +2251,58 @@ def preset_info(
console.print()
@preset_app.command("set-priority")
def preset_set_priority(
pack_id: str = typer.Argument(help="Preset ID"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed preset."""
from .presets import PresetManager
project_root = Path.cwd()
# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = PresetManager(project_root)
# Check if preset is installed
if not manager.registry.is_installed(pack_id):
console.print(f"[red]Error:[/red] Preset '{pack_id}' is not installed")
raise typer.Exit(1)
# Get current metadata
metadata = manager.registry.get(pack_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Preset '{pack_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
from .extensions import normalize_priority
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Preset '{pack_id}' already has priority {priority}[/yellow]")
raise typer.Exit(0)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(pack_id, {"priority": priority})
console.print(f"[green]✓[/green] Preset '{pack_id}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
# ===== Preset Catalog Commands =====
@@ -2578,7 +2640,7 @@ def extension_list(
console.print(f" [{status_color}]{status_icon}[/{status_color}] [bold]{ext['name']}[/bold] (v{ext['version']})")
console.print(f" [dim]{ext['id']}[/dim]")
console.print(f" {ext['description']}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print(f" Commands: {ext['command_count']} | Hooks: {ext['hook_count']} | Priority: {ext['priority']} | Status: {'Enabled' if ext['enabled'] else 'Disabled'}")
console.print()
if available or all_extensions:
@@ -2766,6 +2828,7 @@ def extension_add(
extension: str = typer.Argument(help="Extension name or path"),
dev: bool = typer.Option(False, "--dev", help="Install from local directory"),
from_url: Optional[str] = typer.Option(None, "--from", help="Install from custom URL"),
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
):
"""Install an extension."""
from .extensions import ExtensionManager, ExtensionCatalog, ExtensionError, ValidationError, CompatibilityError
@@ -2779,6 +2842,11 @@ def extension_add(
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
speckit_version = get_speckit_version()
@@ -2795,7 +2863,7 @@ def extension_add(
console.print(f"[red]Error:[/red] No extension.yml found in {source_path}")
raise typer.Exit(1)
manifest = manager.install_from_directory(source_path, speckit_version)
manifest = manager.install_from_directory(source_path, speckit_version, priority=priority)
elif from_url:
# Install from URL (ZIP file)
@@ -2828,7 +2896,7 @@ def extension_add(
zip_path.write_bytes(zip_data)
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
except urllib.error.URLError as e:
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
raise typer.Exit(1)
@@ -2872,7 +2940,7 @@ def extension_add(
try:
# Install from downloaded ZIP
manifest = manager.install_from_zip(zip_path, speckit_version)
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
finally:
# Clean up downloaded ZIP
if zip_path.exists():
@@ -3048,7 +3116,7 @@ def extension_info(
extension: str = typer.Argument(help="Extension ID or name"),
):
"""Show detailed information about an extension."""
from .extensions import ExtensionCatalog, ExtensionManager
from .extensions import ExtensionCatalog, ExtensionManager, normalize_priority
project_root = Path.cwd()
@@ -3085,8 +3153,15 @@ def extension_info(
# Get local manifest info
ext_manifest = manager.get_extension(resolved_installed_id)
metadata = manager.registry.get(resolved_installed_id)
metadata_is_dict = isinstance(metadata, dict)
if not metadata_is_dict:
console.print(
"[yellow]Warning:[/yellow] Extension metadata appears to be corrupted; "
"some information may be unavailable."
)
version = metadata.get("version", "unknown") if metadata_is_dict else "unknown"
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{metadata.get('version', 'unknown')})")
console.print(f"\n[bold]{resolved_installed_name}[/bold] (v{version})")
console.print(f"ID: {resolved_installed_id}")
console.print()
@@ -3114,6 +3189,8 @@ def extension_info(
console.print()
console.print("[green]✓ Installed[/green]")
priority = normalize_priority(metadata.get("priority") if metadata_is_dict else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {resolved_installed_id}")
return
@@ -3129,6 +3206,8 @@ def extension_info(
def _print_extension_info(ext_info: dict, manager):
"""Print formatted extension info from catalog data."""
from .extensions import normalize_priority
# Header
verified_badge = " [green]✓ Verified[/green]" if ext_info.get("verified") else ""
console.print(f"\n[bold]{ext_info['name']}[/bold] (v{ext_info['version']}){verified_badge}")
@@ -3207,6 +3286,9 @@ def _print_extension_info(ext_info: dict, manager):
install_allowed = ext_info.get("_install_allowed", True)
if is_installed:
console.print("[green]✓ Installed[/green]")
metadata = manager.registry.get(ext_info['id'])
priority = normalize_priority(metadata.get("priority") if isinstance(metadata, dict) else None)
console.print(f"[dim]Priority:[/dim] {priority}")
console.print(f"\nTo remove: specify extension remove {ext_info['id']}")
elif install_allowed:
console.print("[yellow]Not installed[/yellow]")
@@ -3233,6 +3315,7 @@ def extension_update(
ValidationError,
CommandRegistrar,
HookExecutor,
normalize_priority,
)
from packaging import version as pkg_version
import shutil
@@ -3272,7 +3355,7 @@ def extension_update(
for ext_id in extensions_to_update:
# Get installed version
metadata = manager.registry.get(ext_id)
if metadata is None or "version" not in metadata:
if metadata is None or not isinstance(metadata, dict) or "version" not in metadata:
console.print(f"{ext_id}: Registry entry corrupted or missing (skipping)")
continue
try:
@@ -3457,13 +3540,13 @@ def extension_update(
shutil.copy2(cfg_file, new_extension_dir / cfg_file.name)
# 9. Restore metadata from backup (installed_at, enabled state)
if backup_registry_entry:
if backup_registry_entry and isinstance(backup_registry_entry, dict):
# Copy current registry entry to avoid mutating internal
# registry state before explicit restore().
current_metadata = manager.registry.get(extension_id)
if current_metadata is None:
if current_metadata is None or not isinstance(current_metadata, dict):
raise RuntimeError(
f"Registry entry for '{extension_id}' missing after install — update incomplete"
f"Registry entry for '{extension_id}' missing or corrupted after install — update incomplete"
)
new_metadata = dict(current_metadata)
@@ -3471,6 +3554,10 @@ def extension_update(
if "installed_at" in backup_registry_entry:
new_metadata["installed_at"] = backup_registry_entry["installed_at"]
# Preserve the original priority (normalized to handle corruption)
if "priority" in backup_registry_entry:
new_metadata["priority"] = normalize_priority(backup_registry_entry["priority"])
# If extension was disabled before update, disable it again
if not backup_registry_entry.get("enabled", True):
new_metadata["enabled"] = False
@@ -3524,7 +3611,7 @@ def extension_update(
# (files that weren't in the original backup)
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None:
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
@@ -3644,10 +3731,10 @@ def extension_enable(
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None:
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already enabled[/yellow]")
raise typer.Exit(0)
@@ -3692,10 +3779,10 @@ def extension_disable(
# Update registry
metadata = manager.registry.get(extension_id)
if metadata is None:
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
if not metadata.get("enabled", True):
console.print(f"[yellow]Extension '{display_name}' is already disabled[/yellow]")
raise typer.Exit(0)
@@ -3717,6 +3804,57 @@ def extension_disable(
console.print(f"To re-enable: specify extension enable {extension_id}")
@extension_app.command("set-priority")
def extension_set_priority(
extension: str = typer.Argument(help="Extension ID or name"),
priority: int = typer.Argument(help="New priority (lower = higher precedence)"),
):
"""Set the resolution priority of an installed extension."""
from .extensions import ExtensionManager
project_root = Path.cwd()
# Check if we're in a spec-kit project
specify_dir = project_root / ".specify"
if not specify_dir.exists():
console.print("[red]Error:[/red] Not a spec-kit project (no .specify/ directory)")
console.print("Run this command from a spec-kit project root")
raise typer.Exit(1)
# Validate priority
if priority < 1:
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
raise typer.Exit(1)
manager = ExtensionManager(project_root)
# Resolve extension ID from argument (handles ambiguous names)
installed = manager.list_installed()
extension_id, display_name = _resolve_installed_extension(extension, installed, "set-priority")
# Get current metadata
metadata = manager.registry.get(extension_id)
if metadata is None or not isinstance(metadata, dict):
console.print(f"[red]Error:[/red] Extension '{extension_id}' not found in registry (corrupted state)")
raise typer.Exit(1)
from .extensions import normalize_priority
raw_priority = metadata.get("priority")
# Only skip if the stored value is already a valid int equal to requested priority
# This ensures corrupted values (e.g., "high") get repaired even when setting to default (10)
if isinstance(raw_priority, int) and raw_priority == priority:
console.print(f"[yellow]Extension '{display_name}' already has priority {priority}[/yellow]")
raise typer.Exit(0)
old_priority = normalize_priority(raw_priority)
# Update priority
manager.registry.update(extension_id, {"priority": priority})
console.print(f"[green]✓[/green] Extension '{display_name}' priority changed: {old_priority}{priority}")
console.print("\n[dim]Lower priority = higher precedence in template resolution[/dim]")
def main():
app()