@@ -2574,6 +2574,39 @@ def _get_manifest(self, pack_dir: Path) -> Optional["PresetManifest"]:
25742574 self ._manifest_cache [key ] = None
25752575 return self ._manifest_cache [key ]
25762576
2577+ def _manifest_declared_template (
2578+ self , pack_dir : Path , template_name : str , template_type : str
2579+ ) -> tuple [dict | None , Path | None ]:
2580+ """Resolve a preset's manifest-declared template entry and usable file.
2581+
2582+ Returns ``(entry, candidate)``:
2583+ - ``entry`` is the matching ``provides.templates`` mapping, or ``None`` if
2584+ the manifest is absent or does not list this ``(name, type)``.
2585+ - ``candidate`` is the declared ``file:`` resolved under ``pack_dir`` IFF
2586+ it is a regular file (``is_file()``); ``None`` otherwise — a missing,
2587+ empty, or non-file (e.g. directory) declaration yields ``(entry, None)``.
2588+
2589+ The manifest is authoritative: when it declares a template (``entry`` is
2590+ not ``None``) but the file is unusable (``candidate`` is ``None``),
2591+ callers must NOT fall back to the convention lookup — that would mask a
2592+ typo or pick up an undeclared file. Shared by ``resolve()`` and
2593+ ``collect_all_layers()`` so their manifest-first resolution cannot
2594+ silently diverge again (the divergence this fix addressed).
2595+ """
2596+ manifest = self ._get_manifest (pack_dir )
2597+ if not manifest :
2598+ return None , None
2599+ for tmpl in manifest .templates :
2600+ if tmpl .get ("name" ) == template_name and tmpl .get ("type" ) == template_type :
2601+ file_path = tmpl .get ("file" )
2602+ if file_path :
2603+ manifest_candidate = pack_dir / file_path
2604+ return tmpl , (
2605+ manifest_candidate if manifest_candidate .is_file () else None
2606+ )
2607+ return tmpl , None
2608+ return None , None
2609+
25772610 def _get_all_extensions_by_priority (self ) -> list [tuple [int , str , dict | None ]]:
25782611 """Build unified list of registered and unregistered extensions sorted by priority.
25792612
@@ -2685,32 +2718,17 @@ def resolve(
26852718 # collect_all_layers()/resolve_content() so resolve() and
26862719 # resolve_with_source() agree with them instead of returning
26872720 # the core template (or a stray convention file).
2688- manifest_file_path = None
2689- manifest_found_entry = False
2690- manifest = self ._get_manifest (pack_dir )
2691- if manifest :
2692- for tmpl in manifest .templates :
2693- if (tmpl .get ("name" ) == template_name
2694- and tmpl .get ("type" ) == template_type ):
2695- manifest_file_path = tmpl .get ("file" )
2696- manifest_found_entry = True
2697- break
2698- if manifest_file_path :
2699- manifest_candidate = pack_dir / manifest_file_path
2700- # is_file() (not exists()) so a manifest ``file:`` that points
2701- # at a directory is treated as missing rather than returned to
2702- # callers that will read_text() it and crash.
2703- if manifest_candidate .is_file ():
2704- return manifest_candidate
2705- # Declared file missing/non-file: skip this pack's convention
2706- # fallback.
2707- continue
2708- if manifest_found_entry :
2709- # Manifest lists this template but with an empty/falsey
2710- # ``file`` value (``file`` is a required key per
2711- # PresetManifest._validate(), so this is a non-usable path,
2712- # not a truly absent one). Don't fall through to convention
2713- # — mirrors collect_all_layers().
2721+ entry , manifest_candidate = self ._manifest_declared_template (
2722+ pack_dir , template_name , template_type
2723+ )
2724+ if manifest_candidate is not None :
2725+ return manifest_candidate
2726+ if entry is not None :
2727+ # Manifest declares this template but the file is missing,
2728+ # non-file (e.g. a directory), or an empty/falsey ``file``
2729+ # value. The manifest is authoritative, so skip this pack's
2730+ # convention fallback rather than mask a typo — mirrors
2731+ # collect_all_layers().
27142732 continue
27152733 for subdir in subdirs :
27162734 if subdir :
@@ -2979,34 +2997,22 @@ def _find_in_subdirs(base_dir: Path) -> Optional[Path]:
29792997 pack_dir = self .presets_dir / pack_id
29802998 # Read strategy and manifest file path from preset manifest
29812999 strategy = "replace"
2982- manifest_file_path = None
29833000 manifest_has_strategy = False
2984- manifest_found_entry = False
2985- manifest = self ._get_manifest (pack_dir )
2986- if manifest :
2987- for tmpl in manifest .templates :
2988- if (tmpl .get ("name" ) == template_name
2989- and tmpl .get ("type" ) == template_type ):
2990- strategy = tmpl .get ("strategy" , "replace" )
2991- manifest_has_strategy = "strategy" in tmpl
2992- manifest_file_path = tmpl .get ("file" )
2993- manifest_found_entry = True
2994- break
2995- # Use manifest file path if specified, otherwise convention-based
2996- # lookup — but only when the manifest doesn't exist or doesn't
2997- # list this template, so preset.yml stays authoritative.
3001+ entry , manifest_candidate = self ._manifest_declared_template (
3002+ pack_dir , template_name , template_type
3003+ )
3004+ if entry is not None :
3005+ strategy = entry .get ("strategy" , "replace" )
3006+ manifest_has_strategy = "strategy" in entry
3007+ # Use the manifest's declared file when it's a usable regular file;
3008+ # only fall back to convention-based lookup when the manifest
3009+ # doesn't list this template at all, so preset.yml stays
3010+ # authoritative (a declared-but-unusable file skips convention —
3011+ # parity with resolve()).
29983012 candidate = None
2999- if manifest_file_path :
3000- manifest_candidate = pack_dir / manifest_file_path
3001- # is_file() (not exists()): a directory at the declared path is
3002- # not a usable layer, so treat it as missing (parity with
3003- # resolve()).
3004- if manifest_candidate .is_file ():
3005- candidate = manifest_candidate
3006- # Explicit file path that isn't a regular file: skip convention
3007- # fallback to avoid masking typos or picking up unintended files.
3008- elif not manifest_found_entry :
3009- # Manifest doesn't list this template — check convention paths
3013+ if manifest_candidate is not None :
3014+ candidate = manifest_candidate
3015+ elif entry is None :
30103016 candidate = _find_in_subdirs (pack_dir )
30113017 if candidate :
30123018 # Legacy fallback: if manifest doesn't explicitly declare a
0 commit comments