Skip to content

Commit d8ec7d3

Browse files
iamaeroplaneclaude
andcommitted
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>
1 parent ceb55ef commit d8ec7d3

File tree

4 files changed

+35
-8
lines changed

4 files changed

+35
-8
lines changed

src/specify_cli/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2831,6 +2831,11 @@ def extension_add(
28312831
console.print("Run this command from a spec-kit project root")
28322832
raise typer.Exit(1)
28332833

2834+
# Validate priority
2835+
if priority < 1:
2836+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
2837+
raise typer.Exit(1)
2838+
28342839
manager = ExtensionManager(project_root)
28352840
speckit_version = get_speckit_version()
28362841

src/specify_cli/extensions.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,14 +328,18 @@ def list_by_priority(self) -> List[tuple]:
328328
"""Get all installed extensions sorted by priority.
329329
330330
Lower priority number = higher precedence (checked first).
331+
Extensions with equal priority are sorted alphabetically by ID
332+
for deterministic ordering.
331333
332334
Returns:
333-
List of (extension_id, metadata) tuples sorted by priority
335+
List of (extension_id, metadata_copy) tuples sorted by priority.
336+
Metadata is deep-copied to prevent accidental mutation.
334337
"""
338+
import copy
335339
extensions = self.data["extensions"]
336340
return sorted(
337-
extensions.items(),
338-
key=lambda item: item[1].get("priority", 10),
341+
[(ext_id, copy.deepcopy(meta)) for ext_id, meta in extensions.items()],
342+
key=lambda item: (item[1].get("priority", 10), item[0]),
339343
)
340344

341345

@@ -469,9 +473,13 @@ def install_from_directory(
469473
Installed extension manifest
470474
471475
Raises:
472-
ValidationError: If manifest is invalid
476+
ValidationError: If manifest is invalid or priority is invalid
473477
CompatibilityError: If extension is incompatible
474478
"""
479+
# Validate priority
480+
if priority < 1:
481+
raise ValidationError("Priority must be a positive integer (1 or higher)")
482+
475483
# Load and validate manifest
476484
manifest_path = source_dir / "extension.yml"
477485
manifest = ExtensionManifest(manifest_path)
@@ -536,9 +544,13 @@ def install_from_zip(
536544
Installed extension manifest
537545
538546
Raises:
539-
ValidationError: If manifest is invalid
547+
ValidationError: If manifest is invalid or priority is invalid
540548
CompatibilityError: If extension is incompatible
541549
"""
550+
# Validate priority early
551+
if priority < 1:
552+
raise ValidationError("Priority must be a positive integer (1 or higher)")
553+
542554
with tempfile.TemporaryDirectory() as tmpdir:
543555
temp_path = Path(tmpdir)
544556

src/specify_cli/presets.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -276,6 +276,10 @@ def remove(self, pack_id: str):
276276
def update(self, pack_id: str, updates: dict):
277277
"""Update preset metadata in registry.
278278
279+
Merges the provided updates with the existing entry, preserving any
280+
fields not specified. The installed_at timestamp is always preserved
281+
from the original entry.
282+
279283
Args:
280284
pack_id: Preset ID
281285
updates: Partial metadata to merge into existing metadata
@@ -285,7 +289,13 @@ def update(self, pack_id: str, updates: dict):
285289
"""
286290
if pack_id not in self.data["presets"]:
287291
raise KeyError(f"Preset '{pack_id}' not found in registry")
288-
self.data["presets"][pack_id].update(updates)
292+
existing = self.data["presets"][pack_id]
293+
# Merge: existing fields preserved, new fields override
294+
merged = {**existing, **updates}
295+
# Always preserve original installed_at
296+
if "installed_at" in existing:
297+
merged["installed_at"] = existing["installed_at"]
298+
self.data["presets"][pack_id] = merged
289299
self._save()
290300

291301
def get(self, pack_id: str) -> Optional[dict]:

tests/test_presets.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ def test_resolve_with_source_extension(self, project_dir):
755755
resolver = PresetResolver(project_dir)
756756
result = resolver.resolve_with_source("unique-template")
757757
assert result is not None
758-
assert "extension:my-ext" in result["source"]
758+
assert result["source"] == "extension:my-ext v1.0.0"
759759

760760
def test_resolve_with_source_not_found(self, project_dir):
761761
"""Test resolve_with_source for nonexistent template."""
@@ -996,7 +996,7 @@ def test_override_beats_pack_beats_extension_beats_core(self, project_dir, pack_
996996
ext_registry.add("my-ext", {"version": "1.0.0", "priority": 10})
997997

998998
result = resolver.resolve_with_source("spec-template")
999-
assert "extension:my-ext" in result["source"]
999+
assert result["source"] == "extension:my-ext v1.0.0"
10001000

10011001
# Install pack — should win over extension
10021002
manager = PresetManager(project_dir)

0 commit comments

Comments
 (0)