Skip to content

Commit 13a78ae

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 13a78ae

File tree

4 files changed

+57
-14
lines changed

4 files changed

+57
-14
lines changed

src/specify_cli/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2000,6 +2000,11 @@ def preset_add(
20002000
console.print("Run this command from a spec-kit project root")
20012001
raise typer.Exit(1)
20022002

2003+
# Validate priority
2004+
if priority < 1:
2005+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
2006+
raise typer.Exit(1)
2007+
20032008
manager = PresetManager(project_root)
20042009
speckit_version = get_speckit_version()
20052010

@@ -2831,6 +2836,11 @@ def extension_add(
28312836
console.print("Run this command from a spec-kit project root")
28322837
raise typer.Exit(1)
28332838

2839+
# Validate priority
2840+
if priority < 1:
2841+
console.print("[red]Error:[/red] Priority must be a positive integer (1 or higher)")
2842+
raise typer.Exit(1)
2843+
28342844
manager = ExtensionManager(project_root)
28352845
speckit_version = get_speckit_version()
28362846

src/specify_cli/extensions.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -328,14 +328,17 @@ 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
"""
335338
extensions = self.data["extensions"]
336339
return sorted(
337-
extensions.items(),
338-
key=lambda item: item[1].get("priority", 10),
340+
[(ext_id, copy.deepcopy(meta)) for ext_id, meta in extensions.items()],
341+
key=lambda item: (item[1].get("priority", 10), item[0]),
339342
)
340343

341344

@@ -469,9 +472,13 @@ def install_from_directory(
469472
Installed extension manifest
470473
471474
Raises:
472-
ValidationError: If manifest is invalid
475+
ValidationError: If manifest is invalid or priority is invalid
473476
CompatibilityError: If extension is incompatible
474477
"""
478+
# Validate priority
479+
if priority < 1:
480+
raise ValidationError("Priority must be a positive integer (1 or higher)")
481+
475482
# Load and validate manifest
476483
manifest_path = source_dir / "extension.yml"
477484
manifest = ExtensionManifest(manifest_path)
@@ -536,9 +543,13 @@ def install_from_zip(
536543
Installed extension manifest
537544
538545
Raises:
539-
ValidationError: If manifest is invalid
546+
ValidationError: If manifest is invalid or priority is invalid
540547
CompatibilityError: If extension is incompatible
541548
"""
549+
# Validate priority early
550+
if priority < 1:
551+
raise ValidationError("Priority must be a positive integer (1 or higher)")
552+
542553
with tempfile.TemporaryDirectory() as tmpdir:
543554
temp_path = Path(tmpdir)
544555

src/specify_cli/presets.py

Lines changed: 29 additions & 7 deletions
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]:
@@ -311,14 +321,18 @@ def list_by_priority(self) -> List[tuple]:
311321
"""Get all installed presets sorted by priority.
312322
313323
Lower priority number = higher precedence (checked first).
324+
Presets with equal priority are sorted alphabetically by ID
325+
for deterministic ordering.
314326
315327
Returns:
316-
List of (pack_id, metadata) tuples sorted by priority
328+
List of (pack_id, metadata_copy) tuples sorted by priority.
329+
Metadata is deep-copied to prevent accidental mutation.
317330
"""
331+
import copy
318332
packs = self.data["presets"]
319333
return sorted(
320-
packs.items(),
321-
key=lambda item: item[1].get("priority", 10),
334+
[(pack_id, copy.deepcopy(meta)) for pack_id, meta in packs.items()],
335+
key=lambda item: (item[1].get("priority", 10), item[0]),
322336
)
323337

324338
def is_installed(self, pack_id: str) -> bool:
@@ -697,9 +711,13 @@ def install_from_directory(
697711
Installed preset manifest
698712
699713
Raises:
700-
PresetValidationError: If manifest is invalid
714+
PresetValidationError: If manifest is invalid or priority is invalid
701715
PresetCompatibilityError: If pack is incompatible
702716
"""
717+
# Validate priority
718+
if priority < 1:
719+
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
720+
703721
manifest_path = source_dir / "preset.yml"
704722
manifest = PresetManifest(manifest_path)
705723

@@ -752,9 +770,13 @@ def install_from_zip(
752770
Installed preset manifest
753771
754772
Raises:
755-
PresetValidationError: If manifest is invalid
773+
PresetValidationError: If manifest is invalid or priority is invalid
756774
PresetCompatibilityError: If pack is incompatible
757775
"""
776+
# Validate priority early
777+
if priority < 1:
778+
raise PresetValidationError("Priority must be a positive integer (1 or higher)")
779+
758780
with tempfile.TemporaryDirectory() as tmpdir:
759781
temp_path = Path(tmpdir)
760782

@@ -1474,7 +1496,7 @@ def resolve(
14741496
if subdir:
14751497
candidate = ext_dir / subdir / f"{template_name}{ext}"
14761498
else:
1477-
candidate = ext_dir / "templates" / f"{template_name}{ext}"
1499+
candidate = ext_dir / f"{template_name}{ext}"
14781500
if candidate.exists():
14791501
return candidate
14801502

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)