Skip to content
Prev Previous commit
Next Next commit
fix: address fourth round of review feedback
- Add type checks for extension/requires/provides top-level fields
  (must be mappings, not null) to raise ValidationError instead of
  AttributeError on malformed manifests
- Add type checks for script entries (must be dict with string
  name/file) to prevent TypeError on non-dict or non-string values
- PresetResolver.resolve() now tries both .sh and .ps1 for scripts
  across all tiers, consistent with list_available() discovery

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
  • Loading branch information
iamaeroplane and claude committed Mar 27, 2026
commit 5afa192ff8a086c0d794e5395963911c7ad42ad5
10 changes: 10 additions & 0 deletions src/specify_cli/extensions.py
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,8 @@ def _validate(self):

# Validate extension metadata
ext = self.data["extension"]
if not isinstance(ext, dict):
raise ValidationError("'extension' must be a mapping")
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
Expand All @@ -135,11 +137,15 @@ def _validate(self):

# Validate requires section
requires = self.data["requires"]
if not isinstance(requires, dict):
raise ValidationError("'requires' must be a mapping")
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
Comment thread
mbachorik marked this conversation as resolved.

# Validate provides section
provides = self.data["provides"]
if not isinstance(provides, dict):
raise ValidationError("'provides' must be a mapping")
commands = provides.get("commands") or []
scripts = provides.get("scripts") or []
if not isinstance(commands, list):
Expand All @@ -165,8 +171,12 @@ def _validate(self):

# Validate scripts
for script in scripts:
if not isinstance(script, dict):
raise ValidationError("Each script entry must be a mapping")
if "name" not in script or "file" not in script:
raise ValidationError("Script missing 'name' or 'file'")
Comment thread
mbachorik marked this conversation as resolved.
if not isinstance(script["name"], str) or not isinstance(script["file"], str):
raise ValidationError("Script 'name' and 'file' must be strings")

# Validate script name format
if not re.match(r'^[a-z0-9-]+$', script["name"]):
Expand Down
39 changes: 20 additions & 19 deletions src/specify_cli/presets.py
Original file line number Diff line number Diff line change
Expand Up @@ -1557,31 +1557,31 @@ def resolve(
else:
subdirs = [""]
Copy link

Copilot AI Mar 30, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PresetResolver.resolve() still accepts unknown template_type values via the else: subdirs = [''] fallback, which means callers can accidentally resolve arbitrary *.md files from preset/extension roots. Since list_available() (and ExtensionResolver._type_config) now reject unsupported types, it would be more consistent (and safer) for resolve() to validate template_type against VALID_PRESET_TEMPLATE_TYPES and raise ValueError for invalid values.

Suggested change
subdirs = [""]
raise ValueError(f"Unsupported template_type: {template_type!r}")

Copilot uses AI. Check for mistakes.

# Determine file extension based on template type
ext = ".md"
if template_type == "script":
ext = ".sh" # scripts use .sh; callers can also check .ps1
# Determine file extensions based on template type
exts = [".sh", ".ps1"] if template_type == "script" else [".md"]

# Priority 1: Project-local overrides
if template_type == "script":
override = self.overrides_dir / "scripts" / f"{template_name}{ext}"
else:
override = self.overrides_dir / f"{template_name}{ext}"
if override.exists():
return override
for file_ext in exts:
if template_type == "script":
override = self.overrides_dir / "scripts" / f"{template_name}{file_ext}"
else:
override = self.overrides_dir / f"{template_name}{file_ext}"
if override.exists():
return override

# Priority 2: Installed presets (sorted by priority — lower number wins)
if self.presets_dir.exists():
registry = PresetRegistry(self.presets_dir)
for pack_id, _metadata in registry.list_by_priority():
pack_dir = self.presets_dir / pack_id
for subdir in subdirs:
if subdir:
candidate = pack_dir / subdir / f"{template_name}{ext}"
else:
candidate = pack_dir / f"{template_name}{ext}"
if candidate.exists():
return candidate
for file_ext in exts:
if subdir:
candidate = pack_dir / subdir / f"{template_name}{file_ext}"
else:
candidate = pack_dir / f"{template_name}{file_ext}"
if candidate.exists():
return candidate

# Priority 3: Extension-provided templates (delegated to ExtensionResolver)
ext_result = self._ext_resolver.resolve(template_name, template_type)
Expand All @@ -1598,9 +1598,10 @@ def resolve(
if core.exists():
return core
elif template_type == "script":
core = self.templates_dir / "scripts" / f"{template_name}{ext}"
if core.exists():
return core
for file_ext in exts:
core = self.templates_dir / "scripts" / f"{template_name}{file_ext}"
if core.exists():
return core

return None

Expand Down
Loading