diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py index 863b6ef7dc..ccf63d33b7 100644 --- a/src/specify_cli/presets/__init__.py +++ b/src/specify_cli/presets/__init__.py @@ -2676,6 +2676,38 @@ def resolve( registry = PresetRegistry(self.presets_dir) for pack_id, _metadata in registry.list_by_priority(): pack_dir = self.presets_dir / pack_id + # The preset manifest is authoritative: if it declares this + # template with an explicit ``file:``, resolve to that path — + # and do NOT fall back to convention when it's missing, to + # avoid masking typos or picking up an undeclared file. Only + # when the manifest is absent or doesn't list this template do + # we use the convention-based subdir lookup. Mirrors + # collect_all_layers()/resolve_content() so resolve() and + # resolve_with_source() agree with them instead of returning + # the core template (or a stray convention file). + manifest_file_path = None + manifest_found_entry = False + manifest = self._get_manifest(pack_dir) + if manifest: + for tmpl in manifest.templates: + if (tmpl.get("name") == template_name + and tmpl.get("type") == template_type): + manifest_file_path = tmpl.get("file") + manifest_found_entry = True + break + if manifest_file_path: + manifest_candidate = pack_dir / manifest_file_path + if manifest_candidate.exists(): + return manifest_candidate + # Declared file missing: skip this pack's convention fallback. + continue + if manifest_found_entry: + # Manifest lists this template but with an empty/falsey + # ``file`` value (``file`` is a required key per + # PresetManifest._validate(), so this is a non-usable path, + # not a truly absent one). Don't fall through to convention + # — mirrors collect_all_layers(). + continue for subdir in subdirs: if subdir: candidate = pack_dir / subdir / f"{template_name}{ext}" diff --git a/tests/test_presets.py b/tests/test_presets.py index 054018b7a0..1029e31ecb 100644 --- a/tests/test_presets.py +++ b/tests/test_presets.py @@ -884,6 +884,84 @@ def test_resolve_pack_takes_priority_over_core(self, project_dir, pack_dir): assert result is not None assert "Custom Spec Template" in result.read_text() + def _install_pack_with_manifest_file(self, project_dir, *, extra_file=False): + """Create a pack whose manifest declares a NON-convention file: path. + + Returns the pack dir under the project. The declared file lives at + custom/spec.md (not the convention templates/spec-template.md). + """ + presets_dir = project_dir / ".specify" / "presets" + pack_dir = presets_dir / "mypack" + (pack_dir / "custom").mkdir(parents=True) + (pack_dir / "custom" / "spec.md").write_text( + "# Manifest-declared Spec\n", encoding="utf-8" + ) + if extra_file: + # An undeclared convention-path file the manifest points away from. + (pack_dir / "templates").mkdir() + (pack_dir / "templates" / "spec-template.md").write_text( + "# Stray Convention Spec\n", encoding="utf-8" + ) + manifest = { + "schema_version": "1.0", + "preset": { + "id": "mypack", + "name": "My Pack", + "version": "1.0.0", + "description": "declares a non-convention file path", + }, + "requires": {"speckit_version": ">=0.1.0"}, + "provides": { + "templates": [ + { + "type": "template", + "name": "spec-template", + "file": "custom/spec.md", + "strategy": "replace", + } + ] + }, + } + with open(pack_dir / "preset.yml", "w") as f: + yaml.dump(manifest, f) + PresetRegistry(presets_dir).add( + "mypack", {"version": "1.0.0", "priority": 10} + ) + return pack_dir + + def test_resolve_uses_manifest_declared_file_path(self, project_dir): + """resolve() must honor a manifest-declared non-convention file: path. + + Previously the tier-2 loop was convention-only, so it returned the + core template and resolve_with_source() misattributed source='core', + diverging from collect_all_layers()/resolve_content(). + """ + pack_dir = self._install_pack_with_manifest_file(project_dir) + resolver = PresetResolver(project_dir) + + result = resolver.resolve("spec-template") + assert result == pack_dir / "custom" / "spec.md" + assert "Manifest-declared Spec" in result.read_text() + + sourced = resolver.resolve_with_source("spec-template") + assert sourced is not None + assert "mypack" in sourced["source"] + # resolve() must agree with collect_all_layers()'s top layer. + layers = resolver.collect_all_layers("spec-template") + assert Path(layers[0]["path"]) == pack_dir / "custom" / "spec.md" + + def test_resolve_manifest_file_wins_over_undeclared_convention_file( + self, project_dir + ): + """A stray convention-path file must not shadow the manifest's file:.""" + pack_dir = self._install_pack_with_manifest_file( + project_dir, extra_file=True + ) + resolver = PresetResolver(project_dir) + result = resolver.resolve("spec-template") + assert result == pack_dir / "custom" / "spec.md" + assert "Manifest-declared Spec" in result.read_text() + def test_resolve_override_takes_priority_over_pack(self, project_dir, pack_dir): """Test that overrides take priority over installed packs.""" # Install the pack