Skip to content

Commit fc1a3fd

Browse files
jawwad-aliclaude
andauthored
fix(presets): resolve() honors manifest-declared file: for installed presets (#3351)
* fix(presets): resolve() honors manifest-declared file: for installed presets PresetResolver.resolve()'s tier-2 (installed presets) loop was convention-only: it looked for templates/<name>.md and <name>.md, ignoring a preset manifest that declares the template with an explicit, non-convention file: path. So resolve() returned the core template (and resolve_with_source() misattributed source='core') while collect_all_layers()/resolve_content() correctly used the preset's declared file — a divergence inside the same class. It could also return a stray convention-path file the manifest deliberately points away from. Mirror collect_all_layers()'s manifest-first logic: use the declared file: when present (skip convention fallback if it's missing, to avoid masking typos), and fall back to the convention walk only when the manifest is absent or doesn't list the template. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(presets): clarify the empty/falsey manifest-file branch comment Per review: 'file' is a required key for every template entry (PresetManifest._validate()), so the manifest-found branch is reached for an empty/falsey/non-usable 'file' value, not a truly absent one. Reword the comment to say so. Comment-only. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(presets): resolve() returns only real files; test missing-file skip Per review: - Use is_file() (not exists()) when honoring a manifest-declared file: so a manifest pointing at a directory is treated as missing rather than returned to a caller that will read_text() it. Applied in both resolve() and collect_all_layers() so the two stay consistent. - Add a regression test for the skip-convention-fallback-when-declared-file- missing behavior: manifest declares a missing custom/spec.md while the pack has a convention templates/spec-template.md; resolve() must skip the pack and fall through to core, not pick up the stray convention file. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(presets): resolve()/collect_all_layers() require a regular file for manifest file: A manifest-declared file: path is honored via exists(), which also accepts a directory. If a preset points file: at a directory, resolve() returned it and downstream read_text() crashes. Use is_file() in both resolve() and collect_all_layers() so a non-file (directory) is treated as missing and the convention fallback is skipped (pack yields to core), matching the existing missing-file behavior. Adds a directory-at-file: test (fails on exists(), passes on is_file()) that also asserts collect_all_layers() never returns the directory as a layer. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(presets): extract shared _manifest_declared_template for resolve()/collect_all_layers() Both methods reimplemented the manifest-entry lookup + authoritative-fallback rules independently — the exact duplication that let them diverge and caused the bug this PR fixes. Extract a single _manifest_declared_template(pack_dir, name, type) -> (entry, candidate) helper (candidate is the declared file only when it is_file(); a declared-but-unusable file returns (entry, None) so callers skip the convention fallback). resolve() and collect_all_layers() now both call it, so their manifest-first resolution cannot silently diverge again. Pure refactor, behavior-preserving: full test_presets.py (331) still passes, including the directory-at-file:, missing-file, and manifest-file-wins cases. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 9930834 commit fc1a3fd

2 files changed

Lines changed: 248 additions & 23 deletions

File tree

src/specify_cli/presets/__init__.py

Lines changed: 68 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -2588,6 +2588,39 @@ def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]:
25882588
self._manifest_cache[key] = None
25892589
return self._manifest_cache[key]
25902590

2591+
def _manifest_declared_template(
2592+
self, pack_dir: Path, template_name: str, template_type: str
2593+
) -> tuple[dict | None, Path | None]:
2594+
"""Resolve a preset's manifest-declared template entry and usable file.
2595+
2596+
Returns ``(entry, candidate)``:
2597+
- ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
2598+
the manifest is absent or does not list this ``(name, type)``.
2599+
- ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
2600+
it is a regular file (``is_file()``); ``None`` otherwise — a missing,
2601+
empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
2602+
2603+
The manifest is authoritative: when it declares a template (``entry`` is
2604+
not ``None``) but the file is unusable (``candidate`` is ``None``),
2605+
callers must NOT fall back to the convention lookup — that would mask a
2606+
typo or pick up an undeclared file. Shared by ``resolve()`` and
2607+
``collect_all_layers()`` so their manifest-first resolution cannot
2608+
silently diverge again (the divergence this fix addressed).
2609+
"""
2610+
manifest = self._get_manifest(pack_dir)
2611+
if not manifest:
2612+
return None, None
2613+
for tmpl in manifest.templates:
2614+
if tmpl.get("name") == template_name and tmpl.get("type") == template_type:
2615+
file_path = tmpl.get("file")
2616+
if file_path:
2617+
manifest_candidate = pack_dir / file_path
2618+
return tmpl, (
2619+
manifest_candidate if manifest_candidate.is_file() else None
2620+
)
2621+
return tmpl, None
2622+
return None, None
2623+
25912624
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
25922625
"""Build unified list of registered and unregistered extensions sorted by priority.
25932626
@@ -2690,6 +2723,27 @@ def resolve(
26902723
registry = PresetRegistry(self.presets_dir)
26912724
for pack_id, _metadata in registry.list_by_priority():
26922725
pack_dir = self.presets_dir / pack_id
2726+
# The preset manifest is authoritative: if it declares this
2727+
# template with an explicit ``file:``, resolve to that path —
2728+
# and do NOT fall back to convention when it's missing, to
2729+
# avoid masking typos or picking up an undeclared file. Only
2730+
# when the manifest is absent or doesn't list this template do
2731+
# we use the convention-based subdir lookup. Mirrors
2732+
# collect_all_layers()/resolve_content() so resolve() and
2733+
# resolve_with_source() agree with them instead of returning
2734+
# the core template (or a stray convention file).
2735+
entry, manifest_candidate = self._manifest_declared_template(
2736+
pack_dir, template_name, template_type
2737+
)
2738+
if manifest_candidate is not None:
2739+
return manifest_candidate
2740+
if entry is not None:
2741+
# Manifest declares this template but the file is missing,
2742+
# non-file (e.g. a directory), or an empty/falsey ``file``
2743+
# value. The manifest is authoritative, so skip this pack's
2744+
# convention fallback rather than mask a typo — mirrors
2745+
# collect_all_layers().
2746+
continue
26932747
for subdir in subdirs:
26942748
if subdir:
26952749
candidate = pack_dir / subdir / f"{template_name}{ext}"
@@ -2957,31 +3011,22 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
29573011
pack_dir = self.presets_dir / pack_id
29583012
# Read strategy and manifest file path from preset manifest
29593013
strategy = "replace"
2960-
manifest_file_path = None
29613014
manifest_has_strategy = False
2962-
manifest_found_entry = False
2963-
manifest = self._get_manifest(pack_dir)
2964-
if manifest:
2965-
for tmpl in manifest.templates:
2966-
if (tmpl.get("name") == template_name
2967-
and tmpl.get("type") == template_type):
2968-
strategy = tmpl.get("strategy", "replace")
2969-
manifest_has_strategy = "strategy" in tmpl
2970-
manifest_file_path = tmpl.get("file")
2971-
manifest_found_entry = True
2972-
break
2973-
# Use manifest file path if specified, otherwise convention-based
2974-
# lookup — but only when the manifest doesn't exist or doesn't
2975-
# list this template, so preset.yml stays authoritative.
3015+
entry, manifest_candidate = self._manifest_declared_template(
3016+
pack_dir, template_name, template_type
3017+
)
3018+
if entry is not None:
3019+
strategy = entry.get("strategy", "replace")
3020+
manifest_has_strategy = "strategy" in entry
3021+
# Use the manifest's declared file when it's a usable regular file;
3022+
# only fall back to convention-based lookup when the manifest
3023+
# doesn't list this template at all, so preset.yml stays
3024+
# authoritative (a declared-but-unusable file skips convention —
3025+
# parity with resolve()).
29763026
candidate = None
2977-
if manifest_file_path:
2978-
manifest_candidate = pack_dir / manifest_file_path
2979-
if manifest_candidate.exists():
2980-
candidate = manifest_candidate
2981-
# Explicit file path that doesn't exist: skip convention
2982-
# fallback to avoid masking typos or picking up unintended files.
2983-
elif not manifest_found_entry:
2984-
# Manifest doesn't list this template — check convention paths
3027+
if manifest_candidate is not None:
3028+
candidate = manifest_candidate
3029+
elif entry is None:
29853030
candidate = _find_in_subdirs(pack_dir)
29863031
if candidate:
29873032
# Legacy fallback: if manifest doesn't explicitly declare a

tests/test_presets.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -884,6 +884,186 @@ def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir):
884884
assert result is not None
885885
assert "Custom Spec Template" in result.read_text()
886886

887+
def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False):
888+
"""Create a pack whose manifest declares a NON-convention file: path.
889+
890+
Returns the pack dir under the project. The declared file lives at
891+
custom/spec.md (not the convention templates/spec-template.md).
892+
"""
893+
presets_dir = project_dir / ".specify" / "presets"
894+
pack_dir = presets_dir / "mypack"
895+
(pack_dir / "custom").mkdir(parents=True)
896+
(pack_dir / "custom" / "spec.md").write_text(
897+
"# Manifest-declared Spec\n", encoding="utf-8"
898+
)
899+
if extra_file:
900+
# An undeclared convention-path file the manifest points away from.
901+
(pack_dir / "templates").mkdir()
902+
(pack_dir / "templates" / "spec-template.md").write_text(
903+
"# Stray Convention Spec\n", encoding="utf-8"
904+
)
905+
manifest = {
906+
"schema_version": "1.0",
907+
"preset": {
908+
"id": "mypack",
909+
"name": "My Pack",
910+
"version": "1.0.0",
911+
"description": "declares a non-convention file path",
912+
},
913+
"requires": {"speckit_version": ">=0.1.0"},
914+
"provides": {
915+
"templates": [
916+
{
917+
"type": "template",
918+
"name": "spec-template",
919+
"file": "custom/spec.md",
920+
"strategy": "replace",
921+
}
922+
]
923+
},
924+
}
925+
with open(pack_dir / "preset.yml", "w") as f:
926+
yaml.dump(manifest, f)
927+
PresetRegistry(presets_dir).add(
928+
"mypack", {"version": "1.0.0", "priority": 10}
929+
)
930+
return pack_dir
931+
932+
def test_resolve_uses_manifest_declared_file_path(self, project_dir):
933+
"""resolve() must honor a manifest-declared non-convention file: path.
934+
935+
Previously the tier-2 loop was convention-only, so it returned the
936+
core template and resolve_with_source() misattributed source='core',
937+
diverging from collect_all_layers()/resolve_content().
938+
"""
939+
pack_dir = self._install_pack_with_manifest_file(project_dir)
940+
resolver = PresetResolver(project_dir)
941+
942+
result = resolver.resolve("spec-template")
943+
assert result == pack_dir / "custom" / "spec.md"
944+
assert "Manifest-declared Spec" in result.read_text()
945+
946+
sourced = resolver.resolve_with_source("spec-template")
947+
assert sourced is not None
948+
assert "mypack" in sourced["source"]
949+
# resolve() must agree with collect_all_layers()'s top layer.
950+
layers = resolver.collect_all_layers("spec-template")
951+
assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md"
952+
953+
def test_resolve_manifest_file_wins_over_undeclared_convention_file(
954+
self, project_dir
955+
):
956+
"""A stray convention-path file must not shadow the manifest's file:."""
957+
pack_dir = self._install_pack_with_manifest_file(
958+
project_dir, extra_file=True
959+
)
960+
resolver = PresetResolver(project_dir)
961+
result = resolver.resolve("spec-template")
962+
assert result == pack_dir / "custom" / "spec.md"
963+
assert "Manifest-declared Spec" in result.read_text()
964+
965+
def test_resolve_skips_convention_when_manifest_file_missing(self, project_dir):
966+
"""When the manifest declares a file: that does not exist, resolve()
967+
must NOT fall back to a convention file in the same pack (that would
968+
mask a typo) — it skips the pack and resolves core instead."""
969+
presets_dir = project_dir / ".specify" / "presets"
970+
pack_dir = presets_dir / "mypack"
971+
# Manifest declares custom/spec.md (MISSING); a convention file exists
972+
# in the pack and must NOT be used.
973+
(pack_dir / "templates").mkdir(parents=True)
974+
(pack_dir / "templates" / "spec-template.md").write_text(
975+
"# Stray Convention Spec\n", encoding="utf-8"
976+
)
977+
manifest = {
978+
"schema_version": "1.0",
979+
"preset": {
980+
"id": "mypack",
981+
"name": "My Pack",
982+
"version": "1.0.0",
983+
"description": "declares a missing file path",
984+
},
985+
"requires": {"speckit_version": ">=0.1.0"},
986+
"provides": {
987+
"templates": [
988+
{
989+
"type": "template",
990+
"name": "spec-template",
991+
"file": "custom/spec.md",
992+
"strategy": "replace",
993+
}
994+
]
995+
},
996+
}
997+
with open(pack_dir / "preset.yml", "w") as f:
998+
yaml.dump(manifest, f)
999+
PresetRegistry(presets_dir).add(
1000+
"mypack", {"version": "1.0.0", "priority": 10}
1001+
)
1002+
1003+
resolver = PresetResolver(project_dir)
1004+
result = resolver.resolve("spec-template")
1005+
assert result is not None
1006+
content = result.read_text()
1007+
assert "Stray Convention Spec" not in content # pack convention skipped
1008+
assert "Core Spec Template" in content # fell through to core
1009+
1010+
def test_resolve_skips_convention_when_manifest_file_is_directory(
1011+
self, project_dir
1012+
):
1013+
"""When the manifest's file: path resolves to a DIRECTORY (not a regular
1014+
file), resolve()/collect_all_layers() must treat it as missing — exists()
1015+
would accept it and downstream read_text() on a directory would crash.
1016+
The pack is skipped (no convention fallback), so core wins."""
1017+
presets_dir = project_dir / ".specify" / "presets"
1018+
pack_dir = presets_dir / "mypack"
1019+
# Declared file: custom/spec.md is created as a DIRECTORY.
1020+
(pack_dir / "custom" / "spec.md").mkdir(parents=True)
1021+
# A convention file also exists and must NOT be used.
1022+
(pack_dir / "templates").mkdir(parents=True)
1023+
(pack_dir / "templates" / "spec-template.md").write_text(
1024+
"# Stray Convention Spec\n", encoding="utf-8"
1025+
)
1026+
manifest = {
1027+
"schema_version": "1.0",
1028+
"preset": {
1029+
"id": "mypack",
1030+
"name": "My Pack",
1031+
"version": "1.0.0",
1032+
"description": "declares a file: that is actually a directory",
1033+
},
1034+
"requires": {"speckit_version": ">=0.1.0"},
1035+
"provides": {
1036+
"templates": [
1037+
{
1038+
"type": "template",
1039+
"name": "spec-template",
1040+
"file": "custom/spec.md",
1041+
"strategy": "replace",
1042+
}
1043+
]
1044+
},
1045+
}
1046+
with open(pack_dir / "preset.yml", "w") as f:
1047+
yaml.dump(manifest, f)
1048+
PresetRegistry(presets_dir).add(
1049+
"mypack", {"version": "1.0.0", "priority": 10}
1050+
)
1051+
1052+
resolver = PresetResolver(project_dir)
1053+
result = resolver.resolve("spec-template")
1054+
assert result is not None
1055+
assert result.is_file() # never a directory
1056+
content = result.read_text()
1057+
assert "Stray Convention Spec" not in content # pack convention skipped
1058+
assert "Core Spec Template" in content # fell through to core
1059+
# collect_all_layers() must agree: the directory is not a layer.
1060+
layers = resolver.collect_all_layers("spec-template")
1061+
assert all(Path(layer["path"]).is_file() for layer in layers)
1062+
assert all(
1063+
Path(layer["path"]) != pack_dir / "custom" / "spec.md"
1064+
for layer in layers
1065+
)
1066+
8871067
def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir):
8881068
"""Test that overrides take priority over installed packs."""
8891069
# Install the pack

0 commit comments

Comments
 (0)